engram: batch-cosine Adapter/Strategy/Factory over ggml (supersedes #114) #116
Reference in New Issue
Block a user
Delete Branch "feat/engram-ggml-cosine-batch"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
Directive from Will: stop hand-rolling GPU compute kernels — use a real, proven, permissively-licensed library instead.
ggml(MIT, the compute library underneathllama.cpp) is installed on this machine as a standalone Homebrew package (brew info ggml, v0.20.0), independent ofllama.cppitself:ggml.h/ggml-backend.h/ggml-metal.h/ggml-cpu.h/ggml-alloc.hunder/opt/homebrew/include,libggml.dylib/libggml-base.dylibunder/opt/homebrew/lib.This is not a rip-and-replace of #114's hand-rolled Metal shader. #114 is real, carefully verified work — its numbers (17.2x, 0.9990 id-recall) are correct and its Metal shader is preserved, unchanged, as one of three selectable strategies behind a new Adapter/Strategy/Factory. This supersedes #114 (which is being closed with a pointer here) rather than sitting alongside it as a second, competing implementation.
ggmlis used here strictly as a bounded compute utility — batched cosine-similarity math — analogous to a VBD Accessor calling out to infrastructure. It does not touch the engram's actual reasoning/graph-traversal/activation-spreading core, which stays 100% own-code.The real ggml API shape (verified against the installed headers + a standalone probe, not assumed)
ggmlships its CPU and Metal implementations as dynamically loaded plugin.sofiles, not statically linkable symbols:Correct usage (verified end-to-end in a standalone probe against a plain-C dot-product reference — bit-correct within float rounding):
ggml_backend_load_all_from_path(dir)—dlopen()s every backend plugin.soindirand registers its device(s). We point this at$(brew --prefix ggml)/libexec(resolved via the stable/opt/homebrew/opt/ggml/libexecsymlink, overridable viaEL_GGML_BACKEND_PATH), falling back toggml_backend_load_all()'s own default search only if that finds nothing — avoids double-registering the same plugins.ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_GPU)→ the registered Metal device.ggml_backend_dev_init(dev, NULL)→ a liveggml_backend_t.ggml_context(no_alloc=true), declare 2D F32 tensors,ggml_mul_mat(node_matrix[dim,n], query_matrix[dim,nq])→out[n,nq]whereout[j*n+i] = dot(node_i, query_j)— ggml's documented convention (A: [k,n],B: [k,m]transposed internally, result[n,m]) maps exactly onto the row-major packed layout #114's kernel already used. One matmul replaces the whole per-row dot-product loop.ggml_backend_alloc_ctx_tensors(ctx, backend)to allocate device buffers,ggml_backend_tensor_set()/_get()to upload/read back,ggml_backend_graph_compute()to run.ggml_mul_matonly computes raw dot products — no notion of "cosine" or this codebase's-2.0sentinel. Per spec: only valid, uniform-dim rows are gathered into the packed matrix sent to the GPU; every excluded row (null/dim-mismatch) gets-2.0scattered back without ever reaching the GPU. Norms are computed on the CPU host in the same pass that already touches every element to gather — free — using the same 4-way partial-sum accumulation #114's kernel and the CPU oracle both use, so the float32 error profile stays comparable across strategies.Structure: Adapter + Strategy + Factory
Selection — env var + build-time + runtime capability probe, all three:
-DEG_HAVE_STRATEGY_GGML -DEG_HAVE_STRATEGY_METAL_HAND); non-Darwin links only the CPU fallback — no Objective-C compiler, no Metal frameworks, matching #114's original Linux behavior exactly (verified: compiles clean,eg_cosine_batch()returnsfalseunconditionally,-Wall -Wextrasilent).available()does the real, cheap-after-first-call check (device present, plugin loaded, pipeline/graph compiles) — never assumed from build-time alone.EL_COSINE_BATCH_STRATEGY=ggml|metal|cpu|autoforces a specific strategy (verified all four paths);EL_METAL_COSINE=0(back-compat with #114) disables every GPU-backed strategy outright.auto, unset):ggml→metal-hand→cpu, first available wins. ggml is what actually runs by default on this machine today — verified viaeg_cosine_batch_strategy_name()— which is what makes "stop hand-rolling GPU kernels" real rather than nominal.Any other genuine batch-numeric call site would route through this same
eg_cosine_batch.h— see the sweep below for why none currently qualifies.Real numbers
Real store snapshot (
~/.neuron/backups/pre-cutover-hnsw-20260815-000735/neuron.egm, a file-based backup — never touched live:8742/:7770), 13,415 real embedded nodes, dim=768, 300 real queries, viavindex_bench store:Synthetic scaling sweep (
vindex_bench sweep 768 13000,25000,50000, dim=768, 150 queries/step):(50,000-node step was still building its HNSW index — ~200s+ for the index alone at this size — when the harness's own timeout cut it off; the trend across 13k→25k already shows both GPU strategies holding a stable ~10-18x margin over CPU as N grows, consistent with #114's original 13k→50k finding.)
Honest tradeoff: ggml vs. hand-rolled Metal
Throughput is statistically tied — sometimes ggml edges ahead (13k synth: 13.2x vs 10.8x), sometimes hand-rolled does (real store: 18.4x vs 18.1x; 25k synth: 12.6x vs 9.5x). Neither strategy is a clear throughput winner at these scales.
Precision is NOT tied. ggml's
-2.0-sentineled cosine consistently shows ~150-190x larger same-rank distance error than the hand-rolled kernel against the CPU double-precision oracle (e.g. real-store: max6.80e-05vs3.58e-07), and correspondingly slightly lower id-recall (0.9933 vs 0.9997 over 300 queries — a handful of near-tied ranks reorder). Both strategies compute in float32 throughout; the gap is most likely ggml's Metal matmul kernel using a different (more parallel, SIMD-group-reduction) accumulation order than the hand-rolled kernel's explicit sequential 4-way unroll — a real, measured difference, not assumed. In absolute terms6.8e-05is still tiny for 768-dim float32 cosine and >99% recall is still strong — but it is a genuine, honest tradeoff, not a wash.Cold-start cost is also not tied. ggml's Metal library init took ~7.8s on a cold cache (first-ever call in the process — it loads/parses its full embedded kernel library, covering every op/type/config combination) vs.
~20mswarm on a subsequent process. The hand-rolled kernel only ever compiles the 2 tiny kernels it actually uses, at "tens of ms." Both costs are one-time-per-process and amortize over a long-lived daemon, but ggml's is materially (~150-400x) larger.Per Will's directive,
ggmlremains the default — the point was "stop hand-rolling GPU kernels," not "prove ggml strictly dominates" — but this tradeoff should be visible to reviewers, not buried.Directive 3 sweep — what else was checked
Re-verified #114's own investigation by reading the current
devcode directly (not re-derived from scratch): the 5eg_cosine(call sites inel_runtime.c— CPU reference impl, O(K=8) dedup (not O(N²)), the lazy/memoizedeg_cosq_at(M8.1 fix — batching this would re-introduce the O(N·D) prescan the codebase deliberately killed),eg_knn_for_node's ~16-candidate HNSW loop (too small for dispatch overhead), and a single pairwise API function — none qualify, confirming #114's read.New candidates investigated this PR, both rejected with evidence:
ingest.el'sfind_existing_by_contentdedup (per a mid-flight ask) — traced its call chain fully:/api/search→engram_retrieve_geometric_json(el_runtime.c~L12609). Read the function in full: it is purely lexical/tag-based (token seed →skill:/shape:/op:region addressing → bounded graph spread) — its own comment says "no cold-embed seed."eg_similarity()exists iningest.elbut is dead code, never called. Not embedding-shaped at all currently — does not qualify.elp/faculty code + projector pipeline — swept every file (audio-surface.el,image-surface.el,speech.el,voice-profile.el,voice-ingest.el,speech-ingest.el,elp/projector/*.py). Found exactly two genuine embarrassingly-parallel batch-numeric loops, both inspeech.el:voice_f0(pitch-lag autocorrelation, ~53-213 lag-candidates × ~1500-3000-sample window) andvoice_peak_in_band(per-frequency-bin Goertzel DFT, ~28 bins × ≤3000 samples). Same "1-query-vs-N-candidates" shape as batch-cosine — but both run only on sub-second sustained-vowel clips today, too small for GPU dispatch overhead to pay off, same reasoning #114 used to correctly reject HNSW's bounded candidate lists. Everything else inelp/is sequential-state synthesis, tiny fixed loops, or small-N string/keyword scoring.Investigated, evidence-backed, deliberately left OUT of this PR (per explicit instruction not to force it in):
eg_embed_fetch,el_runtime.c~L6829) — currently one text → one HTTP POST to Ollama's/api/embeddingsper call, looped sequentially inengram_embed_backfill(up to 64 nodes/call). Real feasibility check:llama.cpp'sllama-embeddingCLI loads the Ollama-managednomic-embed-textblob directly (~/.ollama/models/blobs/sha256-970aa7...— confirmed to be a raw GGUF file despite the missing extension) via ggml/Metal, and with--embd-normalize -1(raw output) produces embeddings cosine 0.9999994 identical to Ollama's own HTTP response for the same text — i.e. numerically compatible with the 13k+ already-stored Ollama-produced embeddings. This is real and promising, but substantially bigger than this PR's scope: it needs batched tokenization via llama.cpp's C API, a persistently-loaded model inside the daemon process, and exact truncation/pooling parity with the current code. Reporting as a strong, evidence-backed follow-on, not half-building it here.Verification performed
-Wall -Wextraclean on every new file (ggml strategy, factory, CPU strategy, ObjC hand-rolled strategy).vindex_bench synthsuccessfully, both GPU strategies correctly reported "not compiled into this build",eg_cosine_batch()returnsfalseunconditionally).eg_cosine_batch.h) exercised standalone exactly as a real call site would: default strategy resolves toggml,-2.0sentinel verified through the full path for a dim-mismatched node, and all fourEL_COSINE_BATCH_STRATEGYvalues (ggml/metal/cpu/unset) plus the legacyEL_METAL_COSINE=0gate all resolve to the correct strategy.vindex_bench's hand-rolled-Metal path re-verified still works unmodified through the new strategy wrapper (id-recall 0.9997-1.0000 across every run above).Blocked / not attempted
Nothing structural.
ggml's dynamic-plugin backend loading works cleanly from a plaincc-built binary once pointed at the rightlibexecdirectory — no build-system blocker.