feat: COMBINED expert-split two-engine mode (hydra_vortex issue #287/#260)#2
Conversation
M-Perf.9 (ggml-org#289) closes the loop on cross-model KV safety: a KV cache built with model A is incompatible with model B, and the Coordinator must never silently restore a cross-model cache. Three changes: * llama_model_hash() — public API + impl that returns the SHA-256 of the GGUF file the model was loaded from. Computed once in llama_model_load_from_file after construction; stored on the llama_model struct as hash_hex. New self-contained SHA-256 helper in src/llama-hash.cpp (no OpenSSL/libcrypto). * State META / GET now carry model_alias + model_hash + model_path so the Coordinator can record which model built each KV. STATE_META (0x32) and STATE_GET (0x30) responses populate these from impl->model_name / impl->params_base.model.path / llama_model_hash(). * Engine PREFILL (0x42) now accepts an optional 'model' key in the request JSON. The C++ side swaps to the requested alias when the preset registry knows it, falls back to the resident model with model_fallback: true in the response when the alias is unknown (or no preset is configured), and behaves as before when 'model' is absent. The engine uses the existing common_preset_context::load_from_ini (common/preset.cpp:315-365) — same INI format llama-server router mode already uses. * Engine INFO (0x41) now advertises the new capabilities ('preset', 'model_hash') and lists preset_aliases. * Engine PIPELINE_ATTACH (0x46) is now defined as a real opcode (previously referenced in WIP that referenced 0x33-0x38 which collides with C# OpCode.GetManifest=0x33) and stubbed to return HYDRA_STATUS_NOT_IMPLEMENTED (new status 0x06) so the Coordinator can distinguish 'not yet built' (issue ggml-org#287) from a real error and fall back to solo mode. The dispatch in hydra_handle_connection routes 0x46 to hydra_handle_pipeline_attach. * server_task_result_hydra_state and server_task_result_hydra_engine gained the model_* fields; to_json() emits them. Refs: ggml-org#289, advances ggml-org#287 Closes: M-Perf.9
…reset alias registry Closes the 4 P0 review blockers from PR ggml-org#290. P0.1 — Add the missing SERVER_TASK_TYPE_HYDRA_ENGINE_PIPELINE_ATTACH entry to the task enum. The PR's handler for 0x46 referenced an undefined symbol — the build would not have compiled. P0.2 — Engine PREFILL now parses the optional 'model' key from the request body. When the alias is in the preset registry, the resident model is swapped via load_model() before the prefill runs; when the alias is unknown (or no preset is configured), the prefill runs on the resident model and the response carries model_fallback:true. The handler also re-derives the slot pointer after load_model since it allocates fresh slots. P0.3 — Engine INFO now returns the actual preset_aliases list from the populated preset_alias_to_path map (was hardcoded empty). P0.4 — Add preset_alias_to_path map to server_context_impl, populated at startup from --models-preset PATH (or directory of .ini files). Uses common_preset_context::load_from_ini and extracts LLAMA_ARG_MODEL from each preset section. Also renumbered the dispatch cases from the old WIP SERVER_TASK_TYPE_HYDRA_* names (0x33-0x38) to the new SERVER_TASK_TYPE_HYDRA_ENGINE_* names (0x40-0x46). The old enum entries are removed (the wire now exclusively uses 0x40-0x46 — the 0x33-0x38 range was a collision zone with the live C# OpCode.GetManifest=0x33). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ookup sites The original PR ggml-org#290 used 'model' as the variable name in 3 places (STATE_GET, STATE_META, and the post-PREFILL response site). The actual variable in server_context_impl is 'model_tgt' (declared at server-context.cpp:680). The build failed with 'model was not declared in this scope' at all 3 sites — caught by a fresh sm_120 cmake build of llama-server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…org#260) Implements the engine-side half of issue ggml-org#287's COMBINED track: a head engine can dual-load its routed-expert tensors onto a peer engine's embedded ggml-RPC backend at startup, then flip per-request between the local copy (SOLO) and the peer copy (COMBINED) via SET_EXPERT_MODE (0x44), with transparent fallback to solo when the peer was never reachable. - Role flag (--role standalone|head|worker, default standalone = no behavior change). --role worker makes the engine a pure embedded ggml-RPC backend server (no model load, no HTTP/control-RPC serving) on --ggml-rpc-port, sidestepping any concurrent local-inference-vs-RPC-server use of the same GPU context. --role head reuses the existing --rpc-engine <peer> flag and adds --combined-ot-pattern <regex>. - llama_hydra_load_combined_experts() (include/llama-hydra.h, src/llama-hydra.cpp): one-time chunked network copy of every expert tensor matching the pattern onto the peer's RPC backend, run once at head startup. Local copies are untouched, so SOLO always still works. - llama_hydra_set_expert_mode()/get_expert_mode(): per-context toggle (llama_cparams.hydra_expert_mode) consumed by qwen35moe.cpp's build_layer_ffn(), which selects the dual-resident _rpc tensors instead of the local ones when COMBINED is active and they were loaded. - SET_EXPERT_MODE (0x44) handler now does real work instead of an unconditional ack: only honors "combined" when dual-load succeeded at startup, and reports the ACTUAL applied mode so the Coordinator's ReportsSolo() can detect the fallback. - ENGINE_INFO (0x41) reports role/mode/peer_connected/peer_addr/ layer_split/combined_capable/pipeline_capable per specs/rpc-protocol.md. Superseded from an earlier, never-finished spike (ggml-org#149/ggml-org#260): this lands the "manual init" the spike's own comments flagged as missing, on top of the current llama-engine (ggml-org#261/ggml-org#262/ggml-org#289), not the old llama-server. Deviation from the original "no weight transfer" framing: COMBINED's dual-residency is populated by the head pushing tensor bytes to the peer over ggml-RPC once at startup (not per-request) — true server-side local loading would require a custom (non-stock) RPC server protocol, which is out of scope here. PIPELINE (the prima.cpp-style layer-split track, 0x46) remains stubbed NOT_IMPLEMENTED — tracked as the remaining half of ggml-org#287. Verified: sm_120 (RTX) builds clean, llama-engine binary's --role validation behaves as designed. sm_60 (P100) not independently built this pass (no arch-specific code in this change; same toolchain/source path). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code review — #2 (feat/m287-combined-expert-mode → hydra-fork)Cross-linked from VerdictReady to merge into What was reviewed
What's good
Cross-repo coordination gaps (minor, not blocking)
What's NOT in this PR (by design)
Open questions for the author
Recommended next steps
|
Re-review —
|
Overview
Engine-side implementation of the COMBINED (expert-split) half of two-engine
"work together" — see
hydra_vortexissue#287 and epic ggml-org#161. The
coordinator side (Hydra.Core) already landed separately and is merged —
this PR is the llama.cpp-fork counterpart it expects.
A "head" engine can dual-load its routed-expert tensors onto a peer engine's
embedded ggml-RPC backend at startup, then flip per-request between the local
copy (SOLO) and the peer copy (COMBINED) via
SET_EXPERT_MODE(0x44), withtransparent fallback to solo when the peer was never reachable.
PIPELINE (the prima.cpp-style layer-split half of ggml-org#287, opcode
0x46)remains stubbed
NOT_IMPLEMENTED— out of scope here, tracked as theremaining half of ggml-org#287 (deliberately split: COMBINED is a finishable, lower-risk
slice; PIPELINE needs new graph-truncation work and a new wire opcode).
What changed
--role standalone|head|worker(defaultstandalone, zerobehavior change for existing deployments).
--role worker --ggml-rpc-port <port>: the process becomes a pureembedded
ggml_backend_rpc_start_serverbackend — no model load, noHTTP/control-RPC serving. This sidesteps running local inference and the
embedded RPC server concurrently against the same GPU context, which is
not a tested/supported pattern in upstream ggml.
--role head: reuses the existing--rpc-engine <peer host:port>flagand adds
--combined-ot-pattern "<tensor-name regex>".llama_hydra_load_combined_experts()(include/llama-hydra.h,src/llama-hydra.cpp): at head startup, after the model loads, copies everyexpert tensor whose name matches the pattern onto the peer's RPC backend
buffer (chunked, 16 MB at a time, so multi-GB expert tensors never need a
full in-memory staging buffer). The head's own local copies are untouched —
SOLO always still works even if this fails.
llama_hydra_set_expert_mode()/get_expert_mode(): per-contexttoggle (
llama_cparams.hydra_expert_mode) consumed byqwen35moe.cpp::build_layer_ffn(), which selects the dual-resident_rpctensors instead of the local ones when COMBINED is active and they were
actually loaded.
SET_EXPERT_MODE(0x44) now does real work instead of anunconditional ack: only honors
"combined"when dual-load succeeded atstartup (
hydra_combined_capable), and reports the actual applied modein the response (
{"success":true,"mode":"solo"|"combined"}) so theCoordinator's
ReportsSolo()can detect a fallback.ENGINE_INFO(0x41) now reportsrole/mode/peer_connected/peer_addr/layer_split/combined_capable/pipeline_capableper
hydra_vortex/specs/rpc-protocol.md.specs/rpc-protocol.md(parent repo) updated to match what's actuallyimplemented (the previous draft's
0x44response shape and theot_splitsyntax description were aspirational/stale).
Design notes / deviations worth flagging for review
server-side local loading. The original design intent ("no weight
transfer — the peer loads from its own local GGUF") would require a custom,
non-stock RPC server protocol (stock
ggml-rpcis a passive memory+computeserver; there's no verb for "load this tensor yourself from disk"). That's
a much larger change and is more naturally part of the PIPELINE half of
In transcript mode the different the person(llama's emulations) the different the day of today. ggml-org/llama.cpp#287 if/when it's needed there too. This PR instead has the head push the
matched tensor bytes to the peer once, at startup — not per-request, so
steady-state inference still never moves weights over the wire, but the
initial framing of "zero weight transfer, ever" doesn't quite hold. Flagging
for discussion in case this isn't an acceptable tradeoff.
("Approach A": a post-graph-build hook that redirects
mul_mat_idcomputeto a different backend without dual-residency). That doesn't actually give
correct SOLO-fallback semantics — redirecting compute to a backend that
doesn't hold the data either forces a per-call copy (defeating the point)
or just doesn't work. This PR ports the spike's
hydra_expert_modecparambut drops that hook in favor of dual-residency ("Approach B"), which is what
the original locked design (
-ot "...=PEER", dual-resident) actually calledfor.
Build / test status
llama-enginetarget, no warningsintroduced.
--rolevalidation smoke-tested manually (invalid role andmissing
--ggml-rpc-portboth fail with clear errors; see test commandsbelow).
change has no architecture-specific code, and both targets compile the same
source through the same CMake path, so risk is low, but it hasn't been
verified on real Pascal hardware.
hardware (dual-load succeeding, a real
SET_EXPERT_MODEround trip,generation quality/perf with COMBINED active). That needs the deployed
--ggml-rpc-portwired intoinfra/hydra-head/config/node-p100.yamlplus alive multi-GPU run — left for the deploy step once this merges (the
Coordinator side already gates COMBINED behind a default-off config flag,
so there's no risk to existing deployments from merging this as-is).
Handoff / next steps for whoever picks up PIPELINE (ggml-org#287 remainder)
during decode (proposed
0x47, not yet defined —0x46 PIPELINE_ATTACHisattach-only).
qwen35moe.cpp'sgraph::graph()constructor: headtruncates the per-layer loop's upper bound and early-returns with
res->t_embdas the boundary activation; worker truncates the lower boundand feeds the received activation in via the already-existing
ubatch.embdraw-vector-input path (llm_graph_context::build_inp_embd,src/llama-graph.cpp) — no new graph-input plumbing needed there.src/llama-model.cpp'screate_memory()filter lambdas need apipeline-split-aware layer-range gate for real KV-cache VRAM savings on
both sides (currently only filters NextN/MTP layers).
(own
llama_context, own KV cache) — it should NOT also run the embeddedggml_backend_rpc_start_serverthread this PR adds for COMBINED's workerrole; that mechanism is COMBINED-specific.
AI usage disclosure: YES — this PR was written by Claude Code, with the
design validated against the actual ggml/llama.cpp source (graph-build
internals, RPC backend API, KV-cache layer filtering) rather than from
memory. A human (repo owner) should review before merge per AGENTS.md/CONTRIBUTING.md.