5d16368817
The DSA lightning indexer selects the top-index_topk (2048) context keys to attend to by finding the threshold = keep-th largest attention score. It previously full-qsorted all nk scores per layer per token (O(nk log nk)) just to read one pivot value, then scanned the original array in position order to build the kept set. Replace the qsort with partial_select_desc (Hoare quickselect, median-of-three, descending): O(nk) average to partition the keep largest into a[0..k), then the threshold is min of that block. The two position-order scans (>thr then ==thr) are UNCHANGED, so the kept-position set is bit-identical -- a stronger contract than #335's sampling heap (which was multiset-only because the heap was unstable and changed accumulation order). The quickselect pivot IS by definition the keep-th largest, so the new threshold equals old tmp[keep-1] exactly. Measured (bench_dsa_select, keep=2048, median of 2000 reps): nk=2049: 119us -> 5.7us (21x) nk=8192: 626us -> 43us (15x) nk=32768: 2.8ms -> 0.28ms (10x) nk=65536: 6.6ms -> 0.47ms (14x) The gap widens with context (linear vs n-log-n). DSA only fires past index_topk, so this is precisely the long-conversation regime where decode latency matters. Adds test_dsa_select (in TEST_BINS): 129 cases asserting element-wise identical kept-set vs an independent qsort reference across shapes (random, peaked, sorted, reverse-sorted, tie-plateau, all-equal) and edges (keep==1, keep==nk). Also directly checks the partition invariant. Adds bench_dsa_select (on-demand, NOT a gate): reproduces the table above.
224 lines
11 KiB
C
224 lines
11 KiB
C
/* DSA top-keep partial-select: the quickselect rewrite (#356) must produce a
|
|
* BIT-IDENTICAL kept-position set to the old full-vocab qsort, for every score
|
|
* shape the attention indexer can see.
|
|
*
|
|
* Why this test exists (#356): attention_rows() selects the top-`keep` context
|
|
* keys (index_topk=2048 on GLM-5.2) to attend to. It previously did this by
|
|
* full-qsorting all `nk` scores (O(nk log nk)) and reading tmp[keep-1] as the
|
|
* threshold. It now does a partial_select_desc (quickselect, O(nk) average) and
|
|
* takes the threshold as the min of the selected top-keep block. The contract is
|
|
* subtle but STRONGER than test_topp's:
|
|
*
|
|
* The two position-order scans that build dst[] --
|
|
* for t: if isc[t] > thr -> keep (strictly above threshold)
|
|
* for t: if isc[t] == thr -> keep (ties, in position order)
|
|
* -- are UNCHANGED by the rewrite. So if the threshold value is identical,
|
|
* the kept-position set is identical element-by-element (not just as a
|
|
* multiset, which is all the unstable sampling heap in #335 could promise).
|
|
*
|
|
* Strategy: drive the REAL partial_select_desc (via the include-glm.c pattern)
|
|
* and replicate the production threshold derivation + scans, then compare the
|
|
* resulting dst[] against an INDEPENDENT reference that re-implements the OLD
|
|
* algorithm (full qsort + tmp[keep-1] threshold) on a private buffer. The kept
|
|
* sets must be element-wise equal on every shape, including tie plateaus where
|
|
* the boundary membership is decided by the position scan.
|
|
*
|
|
* We also directly unit-test partial_select_desc's partition invariant: after
|
|
* the call, max(a[keep..n)) <= min(a[0..keep)) -- i.e. the keep largest really
|
|
* did land in the prefix. This catches a broken quickselect even before the
|
|
* end-to-end comparison.
|
|
*
|
|
* In-memory only (no scratch files), so it builds clean on the Windows MinGW CI
|
|
* job without the unmerged compat shim. */
|
|
#define main coli_glm_main_unused
|
|
#include "../glm.c"
|
|
#undef main
|
|
|
|
#include <math.h>
|
|
|
|
static int g_nfails = 0;
|
|
|
|
#define FAIL(fmt, ...) do { \
|
|
fprintf(stderr, " FAIL [%s nk=%d keep=%d shape=%s]: " fmt "\n", \
|
|
label, nk, keep, shape_name, ##__VA_ARGS__); \
|
|
g_nfails++; \
|
|
return; \
|
|
} while (0)
|
|
|
|
/* ---- independent reference: the OLD algorithm (full qsort + tmp[keep-1]) ---- */
|
|
/* qsort comparator matching the production cmp_fdesc exactly (desc, unstable). */
|
|
static int cmp_ref_desc(const void *a, const void *b){
|
|
float x=*(const float*)a, y=*(const float*)b; return x<y?1:x>y?-1:0; }
|
|
|
|
/* Reproduce the OLD glm.c:2589-2596 exactly: copy, qsort desc, threshold =
|
|
* tmp[keep-1], then the two position-order scans into dst[]. Returns nd. */
|
|
static int keep_old(const float *isc, int nk, int keep, int *dst){
|
|
float *tmp=malloc((size_t)nk*sizeof(float));
|
|
memcpy(tmp,isc,(size_t)nk*sizeof(float));
|
|
qsort(tmp,(size_t)nk,sizeof(float),cmp_ref_desc);
|
|
float thr=tmp[keep-1];
|
|
int nd=0;
|
|
for(int t=0;t<nk && nd<keep;t++) if(isc[t]>thr) dst[nd++]=t;
|
|
for(int t=0;t<nk && nd<keep;t++) if(isc[t]==thr) dst[nd++]=t;
|
|
free(tmp);
|
|
return nd;
|
|
}
|
|
|
|
/* Reproduce the NEW glm.c path: partial_select desc, threshold = min of the
|
|
* selected block, same two scans. Uses the REAL partial_select_desc from glm.c. */
|
|
static int keep_new(const float *isc, int nk, int keep, int *dst){
|
|
float *tmp=malloc((size_t)nk*sizeof(float));
|
|
memcpy(tmp,isc,(size_t)nk*sizeof(float));
|
|
partial_select_desc(tmp,nk,keep);
|
|
float thr=tmp[0]; for(int t=1;t<keep;t++) if(tmp[t]<thr) thr=tmp[t];
|
|
int nd=0;
|
|
for(int t=0;t<nk && nd<keep;t++) if(isc[t]>thr) dst[nd++]=t;
|
|
for(int t=0;t<nk && nd<keep;t++) if(isc[t]==thr) dst[nd++]=t;
|
|
free(tmp);
|
|
return nd;
|
|
}
|
|
|
|
/* ---- direct unit test of the partition invariant ---- */
|
|
static void check_partition(const char *label, const float *isc, int nk, int keep,
|
|
const char *shape_name){
|
|
float *tmp=malloc((size_t)nk*sizeof(float));
|
|
memcpy(tmp,isc,(size_t)nk*sizeof(float));
|
|
partial_select_desc(tmp,nk,keep);
|
|
/* invariant: every element of tmp[0..keep) is >= every element of tmp[keep..n).
|
|
* (>=, not >: equal values may sit on either side of the partition boundary,
|
|
* which is fine -- the threshold is the MIN of the prefix, and the position
|
|
* scan handles ties.) */
|
|
float top_min=INFINITY, tail_max=-INFINITY;
|
|
for(int i=0;i<keep;i++) if(tmp[i]<top_min) top_min=tmp[i];
|
|
for(int i=keep;i<nk;i++) if(tmp[i]>tail_max) tail_max=tmp[i];
|
|
if(!(top_min >= tail_max))
|
|
FAIL("partition invariant violated: top_min=%.9g < tail_max=%.9g", top_min, tail_max);
|
|
free(tmp);
|
|
}
|
|
|
|
/* ---- end-to-end: old vs new kept-set must be element-wise identical ---- */
|
|
static void check_case(const char *label, int nk, int keep, const char *shape_name,
|
|
const float *isc){
|
|
int *da=malloc((size_t)nk*sizeof(int));
|
|
int *db=malloc((size_t)nk*sizeof(int));
|
|
int na=keep_old(isc,nk,keep,da);
|
|
int nb=keep_new(isc,nk,keep,db);
|
|
|
|
/* 1. both keep exactly `keep` positions (the contract: keep the top-keep by
|
|
* count). A count mismatch is a real bug, not a tie artifact. */
|
|
if(na!=keep) FAIL("old kept %d, expected %d (old path is the reference)", na, keep);
|
|
if(nb!=keep) FAIL("new kept %d, expected %d", nb, keep);
|
|
if(na!=nb) FAIL("keep-count mismatch: old=%d new=%d", na, nb);
|
|
|
|
/* 2. element-wise identical dst[]. This is the strong contract: because the
|
|
* threshold is derived identically and the position-order scans are byte-
|
|
* for-byte the same, the kept SET and its ORDER must match exactly. (This
|
|
* is what makes #356 cleaner than #335, which was multiset-only.) */
|
|
int first_diff=-1;
|
|
for(int i=0;i<na;i++){ if(da[i]!=db[i]){ first_diff=i; break; } }
|
|
if(first_diff>=0)
|
|
FAIL("kept-set differs at index %d: old dst[%d]=%d new dst[%d]=%d",
|
|
first_diff, first_diff, da[first_diff], first_diff, db[first_diff]);
|
|
|
|
/* 3. also check the partition invariant directly (catches a subtly broken
|
|
* quickselect even if the threshold happened to come out right). */
|
|
check_partition(label,isc,nk,keep,shape_name);
|
|
|
|
free(da); free(db);
|
|
printf(" ok [nk=%d keep=%d shape=%s]\n", nk, keep, shape_name);
|
|
}
|
|
#undef FAIL
|
|
|
|
/* deterministic xorshift32 RNG (matches the test_i4_grouped.c / test_topp.c convention) */
|
|
static uint32_t rng_state = 0x12345678u;
|
|
static uint32_t xr(void){ rng_state ^= rng_state << 13; rng_state ^= rng_state >> 17;
|
|
rng_state ^= rng_state << 5; return rng_state; }
|
|
static double frand(void){ return (xr() >> 8) * (1.0 / 16777216.0); } /* [0,1) */
|
|
|
|
/* fill scores for a given shape. Shapes stress the threshold boundary and the
|
|
* quickselect's median-of-three pivot differently. */
|
|
static void fill_shape(float *isc, int nk, int shape){
|
|
switch(shape){
|
|
case 0: /* uniform random distinct (no ties): the clean contract case */
|
|
for(int i=0;i<nk;i++) isc[i]=(float)(frand()*1000.0); break;
|
|
case 1: /* peaked: a few hot, long distinct tail (realistic attention shape) */
|
|
for(int i=0;i<nk;i++) isc[i]=(float)(-1.0 - frand()*4.0);
|
|
isc[0]=3.f; if(nk>3) isc[nk/3]=1.f; if(nk>2) isc[nk/2]=0.5f; break;
|
|
case 2: /* strictly decreasing geometric (no ties): sorted input -- worst case
|
|
* for a naive quickselect; median-of-three must handle it */
|
|
for(int i=0;i<nk;i++) isc[i]=(float)(-0.001*(double)i); break;
|
|
case 3: /* strictly increasing (reverse-sorted): the other quickselect worst case */
|
|
for(int i=0;i<nk;i++) isc[i]=(float)(0.001*(double)i); break;
|
|
case 4: /* plateau ties: blocks of equal value -> boundary membership decided
|
|
* entirely by the position scan (exercises the ==thr path) */
|
|
for(int i=0;i<nk;i++) isc[i]=(float)(-(double)(i/7)); break;
|
|
case 5: /* all-equal: every value identical -> degenerate threshold, all kept
|
|
* via the ==thr scan; quickselect must not infinite-loop or corrupt */
|
|
for(int i=0;i<nk;i++) isc[i]=5.f; break;
|
|
}
|
|
}
|
|
|
|
int main(void){
|
|
/* Sizes around the real index_topk=2048 boundary, plus small cases that
|
|
* exercise the k>=n / k==1 / k==n edges. */
|
|
int nks[] = {1, 2, 8, 64, 2049, 4097, 8193};
|
|
int keeps[] = {1, 8, 256, 1024, 2048};
|
|
int n_shapes = 6;
|
|
|
|
int cases = 0;
|
|
for(size_t ni=0; ni<sizeof(nks)/sizeof(nks[0]); ni++){
|
|
int nk=nks[ni];
|
|
float *isc=malloc((size_t)nk*sizeof(float));
|
|
for(int shape=0; shape<n_shapes; shape++){
|
|
/* skip shapes that write out of bounds on tiny nk (fill_shape guards
|
|
* the hot-spots with nk>n, but skip the plateau/geometric edge if nk<7) */
|
|
fill_shape(isc,nk,shape);
|
|
for(size_t ki=0; ki<sizeof(keeps)/sizeof(keeps[0]); ki++){
|
|
int keep=keeps[ki];
|
|
if(keep>nk) continue; /* keep<=nk invariant of the production code */
|
|
if(keep<=0) continue;
|
|
char label[40]; snprintf(label,sizeof(label),"nk[%zu]/keep[%zu]/shape[%d]",ni,ki,shape);
|
|
const char *sn=(const char*[]){"random","peaked","decreasing","increasing","plateau","all-equal"}[shape];
|
|
check_case(label,nk,keep,sn,isc);
|
|
cases++;
|
|
}
|
|
}
|
|
free(isc);
|
|
}
|
|
|
|
/* edge: keep == nk (nothing to partition; both paths keep everything) */
|
|
{
|
|
int nk=100, keep=100; float isc[100];
|
|
for(int i=0;i<nk;i++) isc[i]=(float)frand();
|
|
int *db=malloc(sizeof(int)*nk); int nb=keep_new(isc,nk,keep,db);
|
|
if(nb!=nk){ fprintf(stderr," FAIL [keep==nk]: kept %d expected %d\n",nb,nk); g_nfails++; }
|
|
else printf(" ok [keep==nk nk=%d]\n",nk);
|
|
free(db); cases++;
|
|
}
|
|
/* edge: keep == 1 (threshold = the single max; quickselect must find it) */
|
|
{
|
|
int nk=500, keep=1; float isc[500];
|
|
for(int i=0;i<nk;i++) isc[i]=(float)frand();
|
|
int da[1],db[1]; int na=keep_old(isc,nk,keep,da), nb=keep_new(isc,nk,keep,db);
|
|
if(na!=1||nb!=1||da[0]!=db[0]){
|
|
fprintf(stderr," FAIL [keep==1]: old={%d (n=%d)} new={%d (n=%d)}\n",da[0],na,db[0],nb); g_nfails++; }
|
|
else printf(" ok [keep==1 argmax=%d]\n",db[0]); cases++;
|
|
}
|
|
/* edge: all-equal scores, keep in the middle -> every kept slot is a tie;
|
|
* the position scan must pick positions 0..keep-1 deterministically */
|
|
{
|
|
int nk=1000, keep=500; float isc[1000];
|
|
for(int i=0;i<nk;i++) isc[i]=3.14f;
|
|
int *db=malloc(sizeof(int)*nk); int nb=keep_new(isc,nk,keep,db);
|
|
int bad=0; for(int i=0;i<nb;i++) if(db[i]!=i) bad=1;
|
|
if(nb!=keep||bad){ fprintf(stderr," FAIL [all-equal keep=%d]: nb=%d bad=%d\n",keep,nb,bad); g_nfails++; }
|
|
else printf(" ok [all-equal keep=%d -> positions 0..%d]\n",keep,keep-1); cases++;
|
|
free(db);
|
|
}
|
|
|
|
printf("\ntest_dsa_select: %d cases run, %d failure(s)\n", cases, g_nfails);
|
|
if(g_nfails){ printf("test_dsa_select: FAIL\n"); return 1; }
|
|
printf("test_dsa_select: ok\n");
|
|
return 0;
|
|
}
|