engram: batch-cosine Adapter/Strategy/Factory over ggml, supersedes hand-rolled PR #114
El SDK CI - dev / build-and-test (pull_request) Failing after 4m29s

Stop hand-rolling GPU kernels for batch cosine similarity — use ggml (the
MIT-licensed compute library underneath llama.cpp, installed standalone via
Homebrew) as the preferred backend, without ripping out PR #114's
carefully-verified hand-rolled Metal shader.

Structure: one stable public adapter (eg_cosine_batch.h, zero #ifdef at call
sites) backed by three selectable concrete Strategies behind an internal
vtable (eg_cosine_batch_strategy.h) chosen by a Factory (eg_cosine_batch.c):

  - eg_cosine_batch_strategy_ggml.c    — NEW. ggml + dynamically-loaded Metal
                                          backend plugin (ggml_backend_load_all_from_path
                                          + ggml_mul_mat for the batched dot
                                          product), gather/scatter around the
                                          -2.0 sentinel contract.
  - eg_cosine_batch_strategy_metal_hand.m — PR #114's original hand-rolled
                                          Metal shader bridge, preserved
                                          almost verbatim, now one strategy
                                          among several rather than the only
                                          option. eg_cosine_batch.metal kept
                                          byte-identical to the original.
  - eg_cosine_batch_strategy_cpu.c     — universal always-false fallback
                                          (direct descendant of PR #114's
                                          eg_metal_cosine_stub.c).

Selection: EL_COSINE_BATCH_STRATEGY=ggml|metal|cpu|auto (default: ggml first,
then hand-rolled Metal, then CPU — first available wins), plus back-compat
EL_METAL_COSINE=0 to disable every GPU-backed strategy. build_vindex_bench.sh
compiles all three strategies on Darwin, CPU-fallback-only elsewhere.

vindex_bench.c now reports BRUTE-GGML and BRUTE-METAL side by side against
the same CPU oracle, on the same dataset, in one run (real numbers vs. real
store snapshot in the PR body).
This commit is contained in:
bigmerge
2026-08-15 17:16:41 -05:00
parent 2555e363a6
commit b3f410fc91
9 changed files with 1372 additions and 5 deletions
+121
View File
@@ -0,0 +1,121 @@
/* eg_cosine_batch.c — the Factory. Implements the stable public interface
* declared in eg_cosine_batch.h by selecting ONE concrete
* EgCosineBatchStrategy (eg_cosine_batch_strategy.h) and dispatching every
* call to it. This is the ONLY file that branches on EG_HAVE_STRATEGY_*
* (build-time: which strategy .c/.m files were actually compiled in for
* this platform) — call sites never see those macros.
*
* Selection is lazy (first call) and cached — mirrors the lazy-init caching
* every individual strategy already does internally, so there is no added
* per-call cost after the first.
*
* Selection mechanism (env var + build-time + runtime capability probe, all
* three, exactly as directed):
* - BUILD-TIME decides which strategies exist to choose from at all: a
* Darwin build compiles+links the ggml strategy and the hand-rolled
* Metal strategy (EG_HAVE_STRATEGY_GGML / EG_HAVE_STRATEGY_METAL_HAND
* both defined); a non-Darwin build compiles neither, matching PR #114's
* original Linux behavior exactly (CPU-fallback only, no Objective-C
* compiler or Metal frameworks required).
* - RUNTIME CAPABILITY PROBE: each candidate strategy's own available()
* does the real, cheap-after-first-call check (device present, backend
* plugin loaded, pipeline compiled) — never assumed from build-time
* alone. A build that HAS the ggml strategy compiled in but is running
* on hardware/software where it can't actually initialize (backend
* plugin missing, no GPU) correctly falls through to the next candidate.
* - ENV VAR gives explicit, debuggable override for either axis:
* EL_COSINE_BATCH_STRATEGY = "ggml" | "metal" | "cpu" | unset/"auto"
* forces a specific strategy (falling back to cpu if the forced one
* isn't actually available), or leaves the default auto-preference
* order in place.
* EL_METAL_COSINE = 0/n/N/f/F (back-compat with PR #114's vindex_bench
* gate) disables ALL GPU-backed strategies outright, same as before.
*
* DEFAULT preference order when nothing is forced: ggml, then hand-rolled
* Metal, then CPU fallback — first candidate whose available() reports true
* wins. This is what makes "stop hand-rolling GPU kernels, use ggml" real
* rather than nominal: ggml is what actually runs by default on this
* machine today (see the PR body for the measured numbers backing that).
*/
#include "eg_cosine_batch.h"
#include "eg_cosine_batch_strategy.h"
#include <stdlib.h>
#include <string.h>
static bool g_selected = false;
static const EgCosineBatchStrategy* g_active = NULL;
static bool eg_env_truthy_off(const char* v) {
return v && (v[0]=='0' || v[0]=='n' || v[0]=='N' || v[0]=='f' || v[0]=='F');
}
static const EgCosineBatchStrategy* eg_select_strategy(void) {
if (g_selected) return g_active;
g_selected = true;
const EgCosineBatchStrategy* cpu = eg_cosine_batch_strategy_cpu();
const char* force = getenv("EL_COSINE_BATCH_STRATEGY");
const char* legacy_off = getenv("EL_METAL_COSINE");
if (eg_env_truthy_off(legacy_off)) { g_active = cpu; return g_active; }
if (force && strcmp(force, "cpu") == 0) { g_active = cpu; return g_active; }
if (force && strcmp(force, "ggml") == 0) {
#ifdef EG_HAVE_STRATEGY_GGML
const EgCosineBatchStrategy* s = eg_cosine_batch_strategy_ggml();
if (s->available()) { g_active = s; return g_active; }
#endif
g_active = cpu; return g_active;
}
if (force && strcmp(force, "metal") == 0) {
#ifdef EG_HAVE_STRATEGY_METAL_HAND
const EgCosineBatchStrategy* s = eg_cosine_batch_strategy_metal_hand();
if (s->available()) { g_active = s; return g_active; }
#endif
g_active = cpu; return g_active;
}
/* auto (unset, or any other value): ggml -> metal-hand -> cpu, first
* available wins. */
#ifdef EG_HAVE_STRATEGY_GGML
{
const EgCosineBatchStrategy* s = eg_cosine_batch_strategy_ggml();
if (s->available()) { g_active = s; return g_active; }
}
#endif
#ifdef EG_HAVE_STRATEGY_METAL_HAND
{
const EgCosineBatchStrategy* s = eg_cosine_batch_strategy_metal_hand();
if (s->available()) { g_active = s; return g_active; }
}
#endif
g_active = cpu;
return g_active;
}
bool eg_cosine_batch_available(void) {
return eg_select_strategy()->available();
}
const char* eg_cosine_batch_strategy_name(void) {
return eg_select_strategy()->name;
}
bool eg_cosine_batch(const float* query, int32_t qdim,
const float* const* node_ptrs,
const int32_t* node_dims,
int32_t n,
double* out_scores) {
return eg_select_strategy()->batch(query, qdim, node_ptrs, node_dims, n, out_scores);
}
bool eg_cosine_batch_multi(const float* queries, int32_t qdim, int32_t nq,
const float* const* node_ptrs,
const int32_t* node_dims,
int32_t n,
double* out_scores) {
return eg_select_strategy()->batch_multi(queries, qdim, nq, node_ptrs, node_dims, n, out_scores);
}