Skip to content

WIP: gather-MMVQ pilot (review surface, not complete) - #134

Draft
ddvnguyen wants to merge 1528 commits into
hydra-forkfrom
fork/gather-mmvq-wip
Draft

ddvnguyen wants to merge 1528 commits into
hydra-forkfrom
fork/gather-mmvq-wip

Conversation

@ddvnguyen

Copy link
Copy Markdown
Owner

DRAFT review surface for the compact-gather pilot, exactly as it stands. Not a completion claim. Closes nothing.

Measurement context (ARMs)

  • ARM 000 OFF single-GPU 18.19 tok/s
  • ARM 001 OFF default split 14.39 tok/s
  • ARM 002 OFF single-GPU graphs-disabled 13.13 tok/s (denominator for any syncing build)
  • ARM 003 naive residency 0/4/8 layers = 18.19/19.23/20.55 tok/s, slope +0.295/layer
  • ARM 004 pending (VRAM-matched naive control ~3.4GB, stock llama.cpp)

Scope

  • ggml/src/ggml-cuda/ggml-cuda.cu: dispatch-time compact-gather pilot (+347)
  • src/llama-context.cpp / .h: pin plumbing (+189/+2)
  • src/llama-graph.cpp: hook site (+1)

Known pending: Finding-1 rework (device-side slot_of, in-kernel row-address select; no per-call host readback/sync). Code fixes come after review, not before.

arthw and others added 30 commits September 3, 2026 10:41
Skip the nb[3] check when ne[3] == 1, the shader never reads it for a
single stream. Cache views carry the full-buffer stride there, so the old
check reduced to n_kv == kv_size and the path only engaged with the
cache full.
* CUDA: Allow CUDA optimization per split for multi-GPU.

Previous guard caused multi-GPU to skip the graph optimization.  The
graph is already split per device and the optimization doesnt run
over the whole model but once per split, and thus should be allowed.
However, the CUDA event ggml_cuda_concurrent_event belongs to
whichever GPU was "current" when created. If the pass ran while
GPU 0 was current, it would stick and during event creation for the
second GPU it would land on GPU 0.

The fix: set the device explicitly ggml_cuda_set_device(cuda_ctx->device);
Default behaviour remains unchanged, only active for GGML_CUDA_GRAPH_OPT=1.
Explicit device setting pattern re-used from ggml_backend_cuda_graph_compute.

* Update ggml/src/ggml-cuda/ggml-cuda.cu

Co-authored-by: Aman Gupta <amangupta052@gmail.com>

---------

Co-authored-by: tannerbruhn <tannerbruhn@users.noreply.github.com>
Co-authored-by: Aman Gupta <amangupta052@gmail.com>
* mtmd : mark context as const in more methods

Mark `mtmd_context` as `const` in:
- mtmd_bitmap_init_lazy
- mtmd_tokenize
- mtmd_tokenize_from_parts
- mtmd_helper_support_video
- mtmd_helper_bitmap_init_from_file
- mtmd_helper_bitmap_init_from_buf
- mtmd_helper_video_init
- mtmd_helper_video_init_from_buf
- mtmd_helper_model_can_chat

The tokenization functions in particular are useful to have marked
`const`, as that allows more easily telling the compiler that we can
safely tokenize from multiple threads (`mtmd_tokenize` is already
documented as thread-safe, this just reifies that in the signature).

* mtmd : mark tokenization input pointer as const

Mark the `bitmaps` and `parts` pointers in `mtmd_tokenize` and
`mtmd_tokenize_from_parts` as `const`. This allows more easily calling
these with immutable arrays / vectors.

* mtmd : mark llama_context as const in mtmd_helper_model_can_chat
* metal : fix glu dispatch with ne00 = 1

* tests : disable ill-defined tests
* metal : support n_kv_max sparse mask hint in flash attention vec kernel

- add kernel_flash_attn_ext_vec_idx: compacts finite mask entries into
  a per-row index list (Hillis-Steele scan, one threadgroup per row)
- extend vec FA kernel with optional sparse index gathering (FC slot 5)
- add host-side gate: sparse path when n_kv_max > 0, mask present,
  supported head sizes / KV types, n_kv_max <= 4096
- new buffer region extra_idx for the index list
- pipeline getter extended with has_sparse param
- add test cases: head sizes, quant types, nb>1, nr23 variants,
  sinks, ALiBi, softcap, permute, v_view_of_k, no-mask fallback

Note: multi-row (nb*nr23[1] > 1) cases still failing - rid mapping
in the store phase needs revisiting for the sparse path.

Assisted-by: pi:llama.cpp/Qwen3.8-27B

* metal : fix sparse flash attention row addressing

- kernel_flash_attn_ext_vec_idx: mask param is half* but nb31 is a byte
  stride, so the per-row mask offset was scaled by 2x; cast to char*
  before applying the byte strides
- kernel_flash_attn_ext_vec: sparse pidx param is char* so the per-row
  element offset was under-scaled by sizeof(int); scale it by sizeof(int)
  to get the correct byte offset
- fixes the multi-row (nb*nr23[1] > 1) sparse flash attention failures

Assisted-by: pi:llama.cpp/DeepSeek-v4-0731

* cont : use sparse vec FA for prefill

* metal : single-pass flash attention sparse index compaction

The idx kernel previously read the mask row twice: once to count the finite
entries (for the prefix scan) and again to recover their positions. Since the
kernel is memory-bound, this doubled the mask traffic.

Keep the finite positions in a per-thread register array during the count
pass and write them out directly, avoiding the second mask read. A dense
mask with more than NLOCAL finite entries in a slice falls back to re-reading
the mask to write the remaining positions.

Assisted-by: pi:llama.cpp/DeepSeek-v4-0731

* tests : add perf cases for sparse flash attention prefill

Measure the sparse vec FA kernel across KV sizes, n_kv_max hints and batch
sizes. Run with:

    ./build/bin/test-backend-ops -b MTL0 -o FLASH_ATTN_EXT -p "n_kv_max=[1-9]" perf

Assisted-by: pi:llama.cpp/DeepSeek-v4-0731

* qwen4 : enable sparse attention

* cont : adjust nsg

* cont : sync test-backend-ops

* cont : disable Qwen4 for now

* cont : clean-up + tests
* server : use pytest-xdist for server tests

This commit adds pytest-xdist to the server tests. This is pytest
plugin that distributes test execution across multiple CPU cores.

Assisted-by: pi:llama.cpp/qwen3.8-27B

Refs: ggml-org#26734 (comment)

* remove server_base_port and BASE_PORT

* use worksteal and pytest builting tmp_path
* snapdragon: update CI script to use new snapdragon/run.py

* snapdragon: update build.py to not set +x on /lib
…ns (speculative decoding/MTP) (ggml-org#26477)

* opencl: quant lm_head / decode GEMV and medium-batch GEMM optimizations

* opencl: guard q4_K/q6_K tiled_ns convert-kernel registration for non-Adreno build

* opencl: gate q4_K MUL_MAT+GLU fusion dispatch to Adreno

* opencl: require the noshuffle weight layout in the q4_K GLU fusion gate

* opencl: do not take the vectorized f16 mrow GEMV path on an unaligned row stride

* opencl: pass the new get_scale_min_k4 stride argument at the row-major call sites

* opencl: enable the q4_K split-K decode GEMV only where it is measured to win

* opencl: record the X1-85 split-K datapoint (neutral, exclusion confirmed)

* opencl: restrict the tiled lm_head/embed GEMV default to X2E/A8X

* opencl: fix q4_K variant kernels to read the transposed scales layout

* opencl: keep the flat-GEMV large-m escape opt-in

* opencl: guard the o4 GEMV store against the rounded-up dispatch tail

* opencl: restore the tiled q4_K/q6_K layout on tensor read-back

* opencl: split-K for the q8_0 decode GEMV at small M

* opencl: keep the q6_K noshuffle correctness escape ahead of the opt-in gate
Fuse RMS_NORM+MUL+ADD and ADD+ADD under GGML_SYCL_ENABLE_FUSION.

ADD+ADD uses the same binbcast indexing and type matrix as standalone
add() (f32, f16, f16/f32, i32, i16, bf16, including broadcast and
non-contiguous). Unsupported combinations fall back to two add() launches.
* src : add n_expert_used_max function

With Commit c61b98b ("model: add
NVIDIA Nemotron-3-Puzzle-75B-A9B (NemotronHPuzzle) support (ggml-org#25444)") it
is now possible for each layer to have a specific number of experts but
there are a few checks that need to be updated to handle this upon model
loading. For example:
```console
llama_model_load: error loading model: model has expert layers but no expert layers are used
```
And later:
```console
/llama.cpp/src/llama-model-loader.cpp:955: GGML_ASSERT(n_ids_used > 0) failed
```

This commit adds the n_expert_used_max function so that these checks
can use it.

Refs: ggml-org#25444 (comment)

* src : use hparams.n_expert_used_max in llama_model_base::load_hparams

* src : use 0 as initial value for n_expert_used_max
Signed-off-by: Aaron Teo <aaron.teo1@ibm.com>
Let llama_print_build_info write to a caller-provided FILE* instead of
hardcoding stderr. The parameter defaults to stderr so existing callers
keep their current behavior.

The version command in llama-app now passes stdout, so plain version
output goes to stdout where users expect it.

Signed-off-by: Adrien Gallouët <angt@huggingface.co>
)

Assisted-by: pi:llama.cpp/DeepSeek-V4-Flash-Vision-Exp
Signed-off-by: Adrien Gallouët <angt@huggingface.co>
…8271)

Use std::error_code overloads of fs::current_path() and
fs::directory_iterator in ggml_backend_load_best() so an
inaccessible search path (WebDAV mount, removed CWD) is
skipped instead of terminating the process with an uncaught
filesystem_error.

Signed-off-by: Adrien Gallouët <angt@huggingface.co>
This commit adds a cmake version configuration file to replace the
current compile definition solution for the version.

The motivation for this change is that I made a mistake and did not take
into consideration that the compile definition means that this will
become a compiler flag for all sources in the target. This means that
when a version update happens that will recompile all sources in the
target even if they have not changed.

Refs: ggml-org#28278
* model, mtmd: fix gemma4 vision handling

* nits
* ggml : rename and make private ggml_op_alloc_size_may_expand() (ggml/0)

cont ggml-org#27960

* ggml : bump version to 0.23.0 (ggml/1618)

* sync : ggml
ddvnguyen and others added 20 commits September 17, 2026 00:42
Defer queued requests when resident-ctx-sum + candidate prompt would
reach the threshold; deferred tasks retry on slot release. Auto-slot
path only (explicit id_slot bypasses). Vanilla slot accounting:
max(prompt-cache tokens, full task prompt length) per processing slot.
Env: LLAMA_ARG_PARALLEL_CTX_THRESHOLD.

Co-Authored-By: opencode <noreply@opencode.ai>
Mirror the defer SRV_INF with an admit line (resident + candidate <
threshold) so boundary tests can show the exact accounting either way.

Co-Authored-By: opencode <noreply@opencode.ai>
… exceeding threshold alone

The gate deferred any request with resident + candidate >= threshold,
including resident == 0. A lone request bigger than the threshold on an
otherwise idle pool then defers forever: nothing is resident, so no slot
release ever retries it. The threshold guards combined oversubscription
between concurrent requests, not a single request's own size (that is
bounded by the per-slot cap). Defer now requires resident > 0; a lone
request always admits. Admit log distinguishes the lone case so the
printed comparison stays accurate.

Co-Authored-By: opencode <noreply@opencode.ai>
… one task

A release event re-posted a single deferred task (FIFO head, or one
explicitly requesting the slot). If that task re-deferred - e.g. the
hydra#747 threshold gate deferring a large candidate - the release was
burned and every other waiter stayed queued even with a slot idle, while
brand-new arrivals kept getting served from the main queue (priority
inversion, silent client hangs).

Re-post the entire deferred FIFO in order (explicit-slot matches first)
to the front of the main queue so every waiter is re-evaluated against
the freed capacity before newer arrivals; tasks that still cannot
proceed re-defer to the back, preserving FIFO order.

Deterministic repro + production case: hydra#747 task-12239 hang
(docs/investigations/740-results-report.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rowing

Port upstream danielhanchen/llama.cpp qwen4exp/mtp: draft MTP block
tensors (hc_head_norm/down/up), DECODER_MTP graph, ctx_other borrowing
for shared-embd heads, hybrid MTP memory, gemma4-only mem-share gate.

Assisted-by: Muse Spark (opencode)
Add --rpc-port flag to llama-server to expose a binary TCP listener
that speaks the Hydra RPC protocol (see specs/rpc-protocol.md).

New ops (0x30-0x32):
  STATE_GET  (0x30) — serialize slot KV state, send as response payload
  STATE_PUT  (0x31) — receive KV state bytes, restore to slot
  STATE_META (0x32) — slot metadata only (n_past, state_size, is_processing)

Key design:
  - llama-server knows nothing about Store or sessions
  - key = slot_id as ASCII string; persistent TCP connections
  - Agent pipelines llama RPC socket → Store RPC socket (no 2nd 800 MB buffer)
  - HTTP /slots/:id/state/meta added as debug/curl fallback
  - Thread safety: is_processing() guard for M0; TODO task-queue in M1

Files changed:
  tools/server/server-rpc.h      NEW — wire-format constants (MAGIC, op/status codes)
  tools/server/server-context.h  add start_rpc_server(int port) declaration
  tools/server/server-context.cpp add hydra_* helpers + server_context::start_rpc_server
  tools/server/server-http.h     add put() method declaration
  tools/server/server-http.cpp   implement put() mirroring post()
  tools/server/server.cpp        call start_rpc_server after model load; add HTTP meta route
  common/common.h                add rpc_port = 0 to common_params
  common/arg.cpp                 add --rpc-port / LLAMA_ARG_RPC_PORT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…+ split-cuda script

Cherry-pick -m 1 of b933c30. Applied: socket_t::connection_id()
(transport.cpp/h) + scripts/build-split-cuda.sh.

Skipped: ggml-rpc.cpp hunks (sock-based resolve_bound_socket, stale
remote_ptr guards, alloc-size fallback). N/A on this line: cdd1102
uses the dispatcher model and lacks the hydra-fork get_socket /
re-resolve infra the PR side assumes (same verdict as prior port:
rpc guard N/A). Keeping HEAD dispatcher code avoids a duplicate
get_device_memory definition and ~15 dangling symbol refs.
…gguf-py)

Port upstream ggml-org#28243 shared-tensor borrowing
modules onto baseline cdd1102. C++ graph side (speculative.cpp,
llama-arch, llama-context, llama-model.h, models.h, qwen4exp.cpp,
llama-model.cpp hybrid gate) already matches upstream via local
port cdd1102; this commit adds the 11 missing files:

- conversion/{base,qwen,qwen4exp,bailingmoe3,command_r,dots3,glm}.py:
  mtp_shared_embd flag, qwen4exp MTP export (eh_proj fusion,
  mixer rename, dense compress_ratio trailer, mtp_only PLE skip)
- convert_hf_to_gguf.py: --mtp-shared-embd flag
- gguf-py/gguf/{constants,tensor_mapping}.py: NEXTN_HC_HEAD_*
  registration + QWEN4EXP NextN tensor list
- src/llama-model-loader.h: whitespace sync with upstream

SKIP per brief: overlap/priors/early-router/moe_grouped.
No merge; leader merges.
- New tools/llama-engine/ target: thin exe linking server-context + llama-common + llama
- include/llama-hydra.h + src/llama-hydra.cpp: Hydra-specific APIs isolated from upstream
  - llama_state_seq_get_data_to_fd: zero-copy KV state streaming to socket
  - llama_hydra_peer_reachable: TCP connect test for graceful degradation
- Rename --rpc to --rpc-engine (keep --rpc as hidden alias for one release)
- Graceful degradation: engine starts in SOLO mode if peer unreachable
- Build verified (CPU-only, no warnings)

Part of ggml-org#161 (Epic: phase-optimized inference engine)

Co-Authored-By: opencode <noreply@opencode.ai>
Adapt llama-hydra to_fd wrapper to get_size+get_data+send instead of
69d49e0 zero-copy path; header comment now states the dropped
zero-copy and why (avoid async machinery).

Co-Authored-By: opencode <noreply@opencode.ai>
#44)

server_context::start_rpc_server() declared hydra_rpc_ctx as an
automatic-storage local, then handed hydra_rpc::start() its address to
retain in a process-lifetime singleton (hydra_rpc::state().hydra_ctx),
read by every future RPC connection from a bounded-thread-pool worker
thread. The local went out of scope the moment start_rpc_server()
returned, so the singleton retained a dangling pointer into a freed
stack frame — a stack-use-after-return. Whether a given request's
queue_tasks/queue_results dereference through it "worked" depended
entirely on whether the freed stack slot had been reused yet, which
explains the intermittent nature of #43 (task processed, result
queued, but the response body never reaching the client socket).

Fix: give ctx static storage duration. start_rpc_server() only ever
runs meaningfully once per process (hydra_rpc::start() itself guards
double-start), so `static` gives it exactly the lifetime the singleton
already assumes.

Also make hydra_send_all/hydra_recv_all log on failure (fd, bytes
transferred, errno) instead of silently returning false — every caller
already treats a false return as "give up" but had no way to tell a
real socket error from `-lv` output, which the issue itself flagged as
a diagnostic gap.

Verified live on hardware (RTX 5060 Ti, Qwen3.5-2B-Q8_0): INFO (0x41)
and CONFIGURE (0x40) previously timed out after 10s against a raw
socat client, a raw Python client, and C# RpcClient. All three now
round-trip correctly, including a second request on the same
persistent connection. test-hydra-rpc-bind, test-hydra-configure-tier,
and dotnet Tests.Shared EngineOpcodeTests all still pass.

Closes #43

Co-authored-by: Hydra Engineering <hydra-engineering@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…#451

Port of 860101b adapted to this lineage: progress fields
(operation/progress/tokens_processed/tokens_total/elapsed_ms) reported by
hydra_handle_state_meta from server_slot state/stats. M1 task-queue parts
of the original (hydra_state result structs, PREFILL metrics, get_state
route) skipped - no M1 base in this tree by design (pick 1b option B).

Original: 860101b feat: expose slot progress/status for ggml-org#451 (#55)
Port of f7c8be1. Its checkpoint-before-decode core targets the M1 manual
PREFILL batch loop (opcode 0x42), which does not exist in this tree - our
prefill path is upstream update_slots, which already creates the checkpoint
before llama_decode. No manual PREFILL loop, no hydra_rpc singleton, no
set_routes_ptr base: those hunks skipped. Kept the portable prerequisite:
guarded hydra_rpc.cpp link + include dir in tools/server/CMakeLists.txt
(empty-safe EXISTS guard; no hydra_rpc dir in this tree yet).

Original: f7c8be1 fix(hydra): honor checkpoint-before-decode invariant
slot.n_prompt_tokens_cache / slot.n_decoded do not exist on server_slot
in this lineage (pre-existing breakage since 79ebdf4cf - the hydra RPC
handlers never compiled). Use the stats equivalents the rest of this
tree uses (slot.stats.n_prompt_cached / slot.stats.n_gen, cf. the
n_decoded/n_prompt_tokens_cache task-result mappings).

Found by CPU-only build-check of the 1-series tip (llama + server-context);
both targets now build clean.

Co-Authored-By: opencode <noreply@opencode.ai>
Upstream hc-ops (ggml-org#28901, in rebase base) moved the HC grouped-norm gamma
to [n_embd, hc]; the shared-draft file still carries flat hc_dim gammas.
The layer-norm declarations were already ported with ALLOW_RESHAPE during
the rebase, but NEXTN_HC_HEAD_NORM was missed: the draft's own
blk.*.nextn.hc_head_norm stayed flat and aborted graph_mtp in build_hc_mix
(ggml_can_repeat fail, A=[2560,4,4] vs B=[10240]). Same reshape-tolerant
treatment; verified by strict-config leg on the rebased tree.
Unmarked intermediates can have buffers recycled before extraction
runs, yielding stale contents instead of ids. Stash per-layer
selected_experts and set output when HYDRA_TRACE_ROUTES or
HYDRA_PIN_FILE requests them. Off by default.

Assisted-by: Muse Spark
As-is snapshot of the compact-gather pilot for architect review.
Known pending: Finding-1 rework (device-side slot_of, no per-call
host readback/sync). No completion claimed; closes nothing.

Assisted-by: Muse Spark
@ddvnguyen

Copy link
Copy Markdown
Owner Author

HEADLINE FINDING (architect ruling, banked §19 / commit c5e078d97): the gather hook is installed on the MMVQ dispatch branch, but decode dispatches to MMQ (engagement counters: mmvq=0, mmq=2192, hit=0, miss=0 — zero gather engagements; fused path bypasses the thunk hook). The mechanism as wired is a decode-time no-op; armed tok/s 17.80/17.85 vs 17.7476 same-session anchor confirms stock behaviour. Any review of this surface should treat hook-dispatch placement (MMQ vs MMVQ) as the primary defect. ARM 006 armed-traffic measurement on this build is VOID (no-op), and the related PCIe premise test has been withdrawn as archaeology on a superseded mechanism — see track bank §18/§19 for the CPU-bound pivot (compute/backend split per expert, not weight-address selection).

@ddvnguyen

Copy link
Copy Markdown
Owner Author

Architect review — gather-MMVQ pilot

Reviewed at a744d8019. Verdict: do not continue this direction. The defects below are real and
fixable, but fixing them does not make the design win, so the recommendation is to close this PR in favour
of per-expert backend selection. Details and the arithmetic behind that call are in
docs/leader-handoff-state.md §22.

The number that governs this review

The expert-ranking prize is 5.23 ms/token (12.44 ms/token per 100% of expert work relocated CPU→GPU,
× h=0.4207). The mechanism fires 144 times per token (48 layers × 3 active expert-matmul sites).

36.3 µs per invocation is break-even. 12.1 µs for a 3× margin.

Every finding below is stated against that budget.


B1 — Design: misses are shipped to the GPU instead of computed where their weights live (BLOCKING)

hydra_gather_mmvq, ggml-cuda.cu:2108-2113: every non-pinned expert is copied host→device on every
token
, into a ggml_cuda_pool_alloc that dies with the call — no reuse across tokens.

Cost, using the measured 0.597 MiB per expert per site:

10 used × (1 − h=0.4207) = 5.79 misses × 0.597 MiB × 3 sites × 48 layers ≈ 498 MiB/token
one 0.597 MiB H2D at 8 GB/s ≈ 78 µs  → 2.1× over budget for a SINGLE expert
5.79 misses per invocation  ≈ 450 µs → 12× over budget

The design pays for GPU compute by shipping weights across the bus, and on this rig the bus is the
expensive thing. This is the same failure the dual-construction had (2k experts instead of k), reduced by
h rather than cured by it.

Solution — not a patch, an architecture change. Never move the weights; move the activations. Hits
compute on the GPU against the resident slab; misses compute on the CPU, where their weights already
are
; combine the two partial outputs. This needs a fork-native op on both backends that skips experts it
does not own — CUDA side compacting the promoted subset on-device (prefix-sum over the pin mask, no host
readback), CPU side looping over the miss subset only. That also removes B2 at the root rather than
patching it. Tracked as ARM 008 (one-site spike, measured against the 36.3 µs bar) before any epic.

B2 — Per-call full stream sync (BLOCKING)

ggml-cuda.cu:2070-2071:

CUDA_CHECK(cudaMemcpyAsync(ids_host.data(), ids->data, n_ids*sizeof(int32_t), cudaMemcpyDeviceToHost, stream));
CUDA_CHECK(cudaStreamSynchronize(stream));

~144 host syncs per token, at 10–50 µs each — at or over break-even before any useful work happens.
Credit where due: ggml_cuda_mul_mat_id_needs_sync was correctly extended at :2163-2175 to veto CUDA
graphs on engaged nodes, so this is not a correctness bug. It is a cost bug, and it is fatal to the budget.

Solution: determine the used-expert set on-device. The ids are already on the GPU; a small
prefix-sum/compaction kernel produces the compact mapping without ever touching the host. Then the graph
veto at :2163-2175 can be dropped too, restoring graph capture.

M1 — The sync is uncounted and untimed on the path that actually runs (MEASUREMENT)

hydra_n_sync / hydra_sync_us are incremented only at :2288 and :2316, both in the old fallback
path
. The gather's own sync at :2071 is invisible. We therefore have no instrumentation for the
per-invocation budget on the path under test
— which is precisely the number that decides whether expert
ranking is viable at all.

Solution: time every invocation of the engaged path and report p50/p99 µs per site, not just totals.

M2 — Two different mechanisms share one set of hit counters (MEASUREMENT)

Two hydra paths coexist with different semantics:

  • gather (:2046) — copies misses H2D, counts hydra_hit_exp once per unique expert in the compacted set
  • fallback (:2339-2370) — swaps w_ptr per expert slice, no copies, counts per expert slice with tokens_per_expert > 0

Both increment the same counters, so any run engaging both reports an h that is a mixture of two
incompatible definitions
. Neither matches the paper's h, which is over lookups (k=10 per token), not
over unique rows. Both also gate on ne12 == 1, so the reported h is decode-only — correct for the thesis,
but it must be stated rather than inferred.

Solution: one counter set per path, tagged; count lookups not unique rows; label the decode-only scope
in the dump.

M3 — Pin-file loading is silent on every failure (MEASUREMENT, most important for trust)

hydra_init_once_fn, :1938-1970:

  • fopen failure → silent. A typo in HYDRA_PIN_FILE yields an unarmed run that looks armed.
  • Any line failing sscanf(line, "L %d", &il)continue, silent. A ranker emitting a different format
    loads zero pins, hydra_active() returns false, and the mechanism does nothing — with no error.
  • No summary of what was loaded: no layer count, no experts/layer, no total.

This makes "the mechanism ran and did nothing" indistinguishable from "the mechanism never engaged," which
is exactly the failure mode this track has already been burned by.

Solution: hard-fail on fopen error and on any unparseable line; emit a one-line load summary
(layers, experts/layer, total pins, bytes that will be allocated) at init; and expose an engagement counter
that a test can assert is non-zero before any effect is measured.

R1 — The VRAM slab is invisible to the fit-check and never freed (RESOURCE)

:2022 allocates with raw cudaMalloc outside ggml's buffer accounting, and no cudaFree exists anywhere
for hydra_pin_store::dev_ptr.

At N=38: 38 × 0.597 MiB × 48 layers × 3 sites ≈ 3.2 GB allocated where the fit calculation cannot see it.
That directly compounds #139 — the engine computes a fit, boots, and then dies when the real allocation
lands.

Solution: allocate through a ggml backend buffer so the memory is accounted, and free it with the
backend context.

R2 — Duplicate and out-of-range pins over-allocate silently (RESOURCE)

:2020 sizes the slab as n_slot = hydra_pins[il].size(), but the fill loop at :2024-2027 skips
duplicates and ids >= n_expert. Those slots are allocated and never used.

Solution: deduplicate and bounds-check at load time, and size the slab from the validated set.

N1 — hydra_active() runs std::call_once on the hot path (NIT)

Called at :2217, :2235, :2243, :2251 and from needs_sync, i.e. several times per site per token.
Cheap after completion, but not free, and this is a 36 µs budget.

Solution: resolve once at backend init into a plain bool.


Recommendation

Close this PR. File B1/B2 as superseded-by-design rather than to-fix (already open as #140, #141), and land
M1–M3 + R1–R2 separately regardless of which mechanism wins — they are measurement and resource
correctness, and without M3 in particular no number this track produces is trustworthy.

Next step is ARM 008: a one-site spike of per-expert backend selection, instrumented to report wall time
per invocation against the 36.3 µs break-even and the 12.1 µs 3× bar. Above 36.3 µs, the conversion claim
is refuted on this hardware and I will say so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.