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
+147 -5
View File
@@ -7,11 +7,29 @@
*
* Read-only: never opens a socket, never writes the store. Safe on an nsbx clone.
*
* Build: cc -O2 -std=c11 vindex_bench.c engram_vindex.c -lm -o vindex_bench
* Also runs the brute-force oracle a second (and third) way, through the
* batch-cosine Strategies behind eg_cosine_batch_strategy.h — the ggml
* strategy and the hand-rolled-Metal strategy (Apple/Metal only; see
* eg_cosine_batch.h/eg_cosine_batch_strategy.h) — and reports each one's
* latency + a correctness check against the CPU oracle side-by-side with the
* existing CPU-vs-HNSW numbers. This harness deliberately reaches past the
* single-selection Factory (eg_cosine_batch.c) to instantiate every
* compiled-in strategy directly, so it can compare all of them against the
* SAME dataset in one run — that is the harness's whole job; a real call
* site (el_runtime.c) never does this, it only ever calls the plain
* eg_cosine_batch()/eg_cosine_batch_multi() adapter functions.
* EL_METAL_COSINE=0 forces CPU-only (skips every strategy comparison).
*
* Build (macOS, ggml + hand-rolled Metal): see build_vindex_bench.sh.
* Build (Linux / no Metal): omit every eg_cosine_batch_strategy_*.{c,m} file
* except eg_cosine_batch_strategy_cpu.c — this file never references
* ggml/Metal directly except through the plain-C strategy header, guarded
* by the same EG_HAVE_STRATEGY_* build macros the Factory itself uses.
* Usage: vindex_bench store <neuron.egm> <dim> [nqueries] [k] [ef_csv]
* vindex_bench synth <N> [dim] [clusters] [nqueries] [k] [ef_csv]
*/
#include "engram_vindex.h"
#include "eg_cosine_batch_strategy.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -74,6 +92,106 @@ static double recall_at_k(const int* gt, const uint64_t* ann, int nann, int k){
return (double)hit / (double)k;
}
/* EL_METAL_COSINE: 0/off/false disables EVERY strategy comparison outright
* (falls back to brute_topk() only), matching el_runtime.c's own gate for
* the same env var (back-compat name kept from PR #114; it now gates all
* GPU-backed strategies, not just the hand-rolled Metal one). Unset or any
* other value = try every compiled-in strategy, report each that's
* available, skip (without failing the run) any that isn't. */
static bool g_strategy_env_checked = false;
static bool g_strategy_disabled_by_env = false;
static void eg_strategy_check_env_once(void){
if (g_strategy_env_checked) return;
g_strategy_env_checked = true;
const char* v = getenv("EL_METAL_COSINE");
if (v && (v[0]=='0' || v[0]=='n' || v[0]=='N' || v[0]=='f' || v[0]=='F'))
g_strategy_disabled_by_env = true;
}
/* Batched sibling of brute_topk, generalized over ANY EgCosineBatchStrategy:
* computes top-k for ALL nq queries in ONE strategy->batch_multi() call,
* uploading/preparing the node population exactly once instead of once per
* query. out_ids/out_d are nq*k, row-major (query i's results at
* out_ids+i*k / out_d+i*k). Returns false (nothing written) on any
* failure/unavailability; caller treats that as "skip this strategy in the
* report", never as a hard error. */
static bool batch_topk_strategy(const EgCosineBatchStrategy* strat,
const float* data, int n, int dim,
const float* queries, int nq,
int k, int* out_ids, float* out_d){
if (!strat || !strat->available()) return false;
const float** row_ptrs = malloc((size_t)n * sizeof(float*));
int32_t* dims = malloc((size_t)n * sizeof(int32_t));
double* scores = malloc((size_t)nq * (size_t)n * sizeof(double));
if (!row_ptrs || !dims || !scores) { free(row_ptrs); free(dims); free(scores); return false; }
for (int i = 0; i < n; i++) { row_ptrs[i] = data + (size_t)i * dim; dims[i] = dim; }
bool ok = strat->batch_multi(queries, dim, nq, row_ptrs, dims, n, scores);
free(row_ptrs); free(dims);
if (!ok) { free(scores); return false; }
for (int qi = 0; qi < nq; qi++) {
int* ids = out_ids + (size_t)qi * k;
float* ds = out_d + (size_t)qi * k;
const double* srow = scores + (size_t)qi * n;
for (int i = 0; i < k; i++) { ids[i] = -1; ds[i] = 3.0f; }
for (int i = 0; i < n; i++) {
float d = 1.0f - (float)srow[i]; /* same distance convention as brute_topk */
if (d >= ds[k-1]) continue;
int p = k - 1;
while (p > 0 && ds[p-1] > d) { ds[p] = ds[p-1]; ids[p] = ids[p-1]; p--; }
ds[p] = d; ids[p] = i;
}
}
free(scores);
return true;
}
/* Runs batch_topk_strategy for one named strategy over ALL nq queries, diffs
* against the CPU ground truth (gt/gd, both nq*k), and prints a report line
* in the same shape PR #114 established for BRUTE-METAL — id-recall over
* every query plus the actual max/mean same-rank distance delta across
* every (query,rank) pair that was compared, never fabricated or assumed. */
static void report_strategy_vs_oracle(const char* label, const EgCosineBatchStrategy* strat,
const float* data, int n, int dim,
const float* qv, int nq, int k,
const int* gt, const float* gd, double brute_ms){
if (g_strategy_disabled_by_env) { printf("%-13s: disabled via EL_METAL_COSINE\n", label); return; }
if (!strat || !strat->available()) { printf("%-13s: not available on this build/host — skipped\n", label); return; }
int* gtm = malloc((size_t)nq*k*sizeof(int));
float* gdm = malloc((size_t)nq*k*sizeof(float));
double tm0 = now_s();
bool ok = batch_topk_strategy(strat, data, n, dim, qv, nq, k, gtm, gdm);
double strat_ms = (now_s()-tm0)*1000.0/nq;
if (ok) {
double rec_sum = 0; double max_ddiff = 0; double sum_ddiff = 0; int compared = 0;
for (int i=0;i<nq;i++) {
const int* ids_gt = gt+(size_t)i*k;
const float* d_gt = gd+(size_t)i*k;
const int* ids_m = gtm+(size_t)i*k;
const float* d_m = gdm+(size_t)i*k;
uint64_t idset[512]; int m = (k<512)?k:512;
for (int j=0;j<m;j++) idset[j] = (uint64_t)ids_m[j];
rec_sum += recall_at_k(ids_gt, idset, m, k);
for (int j=0;j<k;j++) {
if (ids_gt[j] == ids_m[j]) {
double diff = fabs((double)d_gt[j]-(double)d_m[j]);
if (diff>max_ddiff) max_ddiff=diff;
sum_ddiff += diff; compared++;
}
}
}
printf("%-13s: %8.3f ms/query (%.1fx vs CPU brute; id-recall %.4f vs CPU oracle over %d queries; same-rank |Δdist|: max %.2e, mean %.2e over %d compared)\n",
label, strat_ms, brute_ms/strat_ms, rec_sum/nq, nq, max_ddiff, compared?sum_ddiff/compared:0.0, compared);
} else {
printf("%-13s: batch call failed mid-run — skipped\n", label);
}
free(gtm); free(gdm);
}
/* Parse "64,128,256" into an int array; returns count. */
static int parse_csv(const char* s, int* out, int maxo){
int n=0; if(!s||!*s) return 0;
@@ -142,13 +260,37 @@ static void run_bench(const char* label, float* data, int n, int dim,
l2norm(dst, dim);
}
/* ground truth: brute-force top-k for every query (also the oracle latency). */
/* ground truth: brute-force top-k for every query (also the oracle latency).
* gd is nq*k (one real slot per query, not a shared scratch buffer) so the
* strategy comparisons below can diff against every query's actual
* distances, not just whichever query happened to run last. */
int* gt = malloc((size_t)nq*k*sizeof(int));
float* gd = malloc((size_t)k*sizeof(float));
float* gd = malloc((size_t)nq*k*sizeof(float));
double tb0 = now_s();
for (int i=0;i<nq;i++) brute_topk(data, n, dim, qv+(size_t)i*dim, k, gt+(size_t)i*k, gd);
for (int i=0;i<nq;i++) brute_topk(data, n, dim, qv+(size_t)i*dim, k, gt+(size_t)i*k, gd+(size_t)i*k);
double brute_ms = (now_s()-tb0)*1000.0/nq;
printf("BRUTE-FORCE : %8.3f ms/query (oracle; O(N*D))\n", brute_ms);
printf("BRUTE-FORCE : %8.3f ms/query (oracle; O(N*D), CPU)\n", brute_ms);
/* GPU-backed oracles: SAME nq queries, SAME top-k contract, via each
* compiled-in Strategy's batch_multi() (uploads/prepares the node
* population once, not once per query). Run only for strategies that
* are actually available (checked internally) — never fabricated, never
* assumed. Verified against the CPU ground truth computed above:
* id-recall across ALL nq queries, plus the actual max/mean distance
* delta across every (query,rank) pair that was compared. */
eg_strategy_check_env_once();
#ifdef EG_HAVE_STRATEGY_GGML
report_strategy_vs_oracle("BRUTE-GGML", eg_cosine_batch_strategy_ggml(),
data, n, dim, qv, nq, k, gt, gd, brute_ms);
#else
printf("BRUTE-GGML : strategy not compiled into this build\n");
#endif
#ifdef EG_HAVE_STRATEGY_METAL_HAND
report_strategy_vs_oracle("BRUTE-METAL", eg_cosine_batch_strategy_metal_hand(),
data, n, dim, qv, nq, k, gt, gd, brute_ms);
#else
printf("BRUTE-METAL : strategy not compiled into this build\n");
#endif
/* HNSW at each ef. */
uint64_t* aid = malloc((size_t)k*sizeof(uint64_t));