/* 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 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); }