b3f410fc91
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).
157 lines
6.8 KiB
Metal
157 lines
6.8 KiB
Metal
/* eg_cosine_batch.metal — batched cosine similarity, one query vs N node vectors.
|
|
*
|
|
* GPU-shaped counterpart to eg_cosine() in el_runtime.c: same math, same
|
|
* dim-mismatch/zero-norm sentinel (-2.0), applied to N independent rows in
|
|
* parallel instead of one pair at a time in a CPU loop.
|
|
*
|
|
* Semantics MUST match eg_cosine() exactly:
|
|
* - inputs are raw, UNNORMALIZED vectors (nomic-embed-text magnitudes are
|
|
* not 1.0) — this kernel computes the full dot/(|a|*|b|) cosine, not a
|
|
* plain dot product.
|
|
* - a node whose declared dim differs from the query dim, or whose norm is
|
|
* zero, scores exactly -2.0 (below any valid cosine in [-1,1]), so a
|
|
* caller doing `if (score > threshold)` behaves identically whether the
|
|
* scalar or the batched path filled the array.
|
|
*
|
|
* Precision: Apple GPUs do not support double in Metal Shading Language —
|
|
* everything here is float32. eg_cosine accumulates in CPU double, but its
|
|
* *inputs* are float32 embeddings, so the achievable precision ceiling is
|
|
* bounded by the input data regardless of accumulator width. To keep the
|
|
* float32 reduction from drifting relative to the double-accumulated CPU
|
|
* result across dim=768 terms, each thread accumulates with 4 independent
|
|
* partial sums (unrolled) rather than one running scalar — the same
|
|
* error-reduction trick already used by the CPU brute-force loop in
|
|
* vindex_bench.c. The measured float-vs-double delta is reported in the PR
|
|
* description; this is not assumed to be "close enough" without measurement.
|
|
*/
|
|
#include <metal_stdlib>
|
|
using namespace metal;
|
|
|
|
/* Per-dispatch invariants. `dim` is the query's dimensionality — the
|
|
* dimensionality every comparable node vector must match. */
|
|
struct EgCosineParams {
|
|
uint n; /* number of node rows */
|
|
uint dim; /* vector width (both query and node rows are `dim` wide in
|
|
* the packed buffer; node_dims[] carries each node's REAL
|
|
* embedded dim for the mismatch check) */
|
|
};
|
|
|
|
/* One thread per node row. node_matrix is n*dim floats, row-major, packed at
|
|
* `dim` stride regardless of a row's real dim (the CPU side zero-pads or
|
|
* skips packing rows that don't match — see eg_cosine_batch_metal in
|
|
* eg_metal_cosine.m for the exact packing contract). node_dims[i] is the
|
|
* node's true emb_dim, used only for the mismatch sentinel — never used to
|
|
* index, since every row is packed at uniform `dim` stride. */
|
|
kernel void eg_cosine_batch_kernel(
|
|
device const float* query [[buffer(0)]],
|
|
device const float* node_matrix [[buffer(1)]],
|
|
device const int* node_dims [[buffer(2)]],
|
|
constant EgCosineParams& p [[buffer(3)]],
|
|
device float* out_scores [[buffer(4)]],
|
|
uint gid [[thread_position_in_grid]])
|
|
{
|
|
if (gid >= p.n) return;
|
|
|
|
if (node_dims[gid] != int(p.dim)) {
|
|
out_scores[gid] = -2.0f;
|
|
return;
|
|
}
|
|
|
|
device const float* row = node_matrix + (uint64_t)gid * (uint64_t)p.dim;
|
|
|
|
/* 4-way partial accumulation — same shape as vindex_bench.c's brute_topk
|
|
* unroll, done here for float32 accuracy rather than raw throughput. */
|
|
float dot0 = 0.0f, dot1 = 0.0f, dot2 = 0.0f, dot3 = 0.0f;
|
|
float na0 = 0.0f, na1 = 0.0f, na2 = 0.0f, na3 = 0.0f;
|
|
float nb0 = 0.0f, nb1 = 0.0f, nb2 = 0.0f, nb3 = 0.0f;
|
|
|
|
uint d = 0;
|
|
uint dim4 = p.dim & ~3u;
|
|
for (; d < dim4; d += 4) {
|
|
float a0 = row[d], b0 = query[d];
|
|
float a1 = row[d+1], b1 = query[d+1];
|
|
float a2 = row[d+2], b2 = query[d+2];
|
|
float a3 = row[d+3], b3 = query[d+3];
|
|
dot0 += a0*b0; dot1 += a1*b1; dot2 += a2*b2; dot3 += a3*b3;
|
|
na0 += a0*a0; na1 += a1*a1; na2 += a2*a2; na3 += a3*a3;
|
|
nb0 += b0*b0; nb1 += b1*b1; nb2 += b2*b2; nb3 += b3*b3;
|
|
}
|
|
float dot = (dot0 + dot1) + (dot2 + dot3);
|
|
float na = (na0 + na1) + (na2 + na3);
|
|
float nb = (nb0 + nb1) + (nb2 + nb3);
|
|
for (; d < p.dim; d++) {
|
|
float a = row[d], b = query[d];
|
|
dot += a*b; na += a*a; nb += b*b;
|
|
}
|
|
|
|
if (na <= 0.0f || nb <= 0.0f) {
|
|
out_scores[gid] = -2.0f;
|
|
return;
|
|
}
|
|
out_scores[gid] = dot / sqrt(na * nb);
|
|
}
|
|
|
|
/* ── multi-query variant ──────────────────────────────────────────────────
|
|
* Same per-pair math as eg_cosine_batch_kernel, but amortizes ONE upload of
|
|
* node_matrix (the expensive part at real store size — 13k*768 floats is
|
|
* ~42MB) across `nq` queries instead of re-uploading it once per query.
|
|
* Measured need: a naive one-query-at-a-time loop calling the single-query
|
|
* kernel nq times was SLOWER than the CPU oracle at N≈13.7k (re-gather +
|
|
* re-upload dominated the actual compute) — this is the fix, not a
|
|
* hypothetical optimization.
|
|
*
|
|
* 2D grid: x = node index [0,n), y = query index [0,nq). out_scores is
|
|
* nq*n, row-major by query (out_scores[qid*n + nid]). */
|
|
struct EgCosineMultiParams { uint n; uint dim; uint nq; };
|
|
|
|
kernel void eg_cosine_batch_multi_kernel(
|
|
device const float* queries [[buffer(0)]], /* nq*dim */
|
|
device const float* node_matrix [[buffer(1)]], /* n*dim */
|
|
device const int* node_dims [[buffer(2)]], /* n */
|
|
constant EgCosineMultiParams& p [[buffer(3)]],
|
|
device float* out_scores [[buffer(4)]], /* nq*n */
|
|
uint2 gid [[thread_position_in_grid]])
|
|
{
|
|
uint nid = gid.x, qid = gid.y;
|
|
if (nid >= p.n || qid >= p.nq) return;
|
|
|
|
uint64_t out_idx = (uint64_t)qid * (uint64_t)p.n + (uint64_t)nid;
|
|
|
|
if (node_dims[nid] != int(p.dim)) {
|
|
out_scores[out_idx] = -2.0f;
|
|
return;
|
|
}
|
|
|
|
device const float* row = node_matrix + (uint64_t)nid * (uint64_t)p.dim;
|
|
device const float* query = queries + (uint64_t)qid * (uint64_t)p.dim;
|
|
|
|
float dot0 = 0.0f, dot1 = 0.0f, dot2 = 0.0f, dot3 = 0.0f;
|
|
float na0 = 0.0f, na1 = 0.0f, na2 = 0.0f, na3 = 0.0f;
|
|
float nb0 = 0.0f, nb1 = 0.0f, nb2 = 0.0f, nb3 = 0.0f;
|
|
|
|
uint d = 0;
|
|
uint dim4 = p.dim & ~3u;
|
|
for (; d < dim4; d += 4) {
|
|
float a0 = row[d], b0 = query[d];
|
|
float a1 = row[d+1], b1 = query[d+1];
|
|
float a2 = row[d+2], b2 = query[d+2];
|
|
float a3 = row[d+3], b3 = query[d+3];
|
|
dot0 += a0*b0; dot1 += a1*b1; dot2 += a2*b2; dot3 += a3*b3;
|
|
na0 += a0*a0; na1 += a1*a1; na2 += a2*a2; na3 += a3*a3;
|
|
nb0 += b0*b0; nb1 += b1*b1; nb2 += b2*b2; nb3 += b3*b3;
|
|
}
|
|
float dot = (dot0 + dot1) + (dot2 + dot3);
|
|
float na = (na0 + na1) + (na2 + na3);
|
|
float nb = (nb0 + nb1) + (nb2 + nb3);
|
|
for (; d < p.dim; d++) {
|
|
float a = row[d], b = query[d];
|
|
dot += a*b; na += a*a; nb += b*b;
|
|
}
|
|
|
|
if (na <= 0.0f || nb <= 0.0f) {
|
|
out_scores[out_idx] = -2.0f;
|
|
return;
|
|
}
|
|
out_scores[out_idx] = dot / sqrt(na * nb);
|
|
}
|