327 lines
12 KiB
C
327 lines
12 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_attn_ring_retry.c
|
|
* @brief Unit test for the attention-path tracker: the bounded retry after a
|
|
* failed per-slot KV-ring allocation, and the ondie/host residency
|
|
* gauge. Host-side, no GPU, no model.
|
|
*
|
|
* WHAT IT GUARDS
|
|
* afcad12 made a failed gpu_attn_slot_ring_ensure STICKY: the layer recorded
|
|
* the geometry and never attempted it again. The reason was sound — an
|
|
* unbraked retry re-attempted a 144 MiB cuMemAlloc every decode step and
|
|
* bumped slots_ring_gen with it, dropping every layer's captured graph, for
|
|
* a measured 62% collapse. The cure was worse in a different direction: ONE
|
|
* transient allocation failure pinned that layer to the host-softmax
|
|
* fallback for the life of the process. On the depth bench that fallback is
|
|
* 14.58 -> 0.12 tok/s, with correct output and no error.
|
|
*
|
|
* This test pins the three halves of "bounded, and not a latch":
|
|
*
|
|
* - a TRANSIENT failure must recover (the whole point),
|
|
* - the retry must STOP (PN_ATTN_RING_MAX_FAILURES), and a success must
|
|
* reset the budget so recurring contention never exhausts it,
|
|
* - an IMPOSSIBLE geometry — one that could not fit an EMPTY die — must
|
|
* give up on the first failure, because there is nothing to wait for.
|
|
* That is the distinction that keeps a doomed layer from spending four
|
|
* rounds of pointless multi-hundred-MiB allocations.
|
|
*
|
|
* Plus the gauge itself: ondie + host == total, layers the forward never
|
|
* reached are excluded rather than reported as a deficit, and the host
|
|
* reason is carried so an operator learns WHY, not just that.
|
|
*
|
|
* RED-FIRST
|
|
* `--latch` answers every allocation question with the OLD sticky rule — one
|
|
* attempt at a geometry, then never again — which is exactly what
|
|
* kv_slots_fail_{seq,n,q4} did. It MUST fail. A gate nobody has watched go
|
|
* red is not evidence, so the failing arm ships with the passing one.
|
|
*
|
|
* @status Complete
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
#include "../src/models/gpu_attn_path_tracker.h"
|
|
|
|
static int failures = 0;
|
|
#define CHECK(cond, msg) do { \
|
|
if (cond) { printf(" PASS: %s\n", msg); } \
|
|
else { printf(" FAIL: %s (%s:%d)\n", msg, __FILE__, __LINE__); failures++; } \
|
|
} while (0)
|
|
|
|
/* When set, the allocation question is answered by the OLD sticky latch
|
|
* instead of the tracker's backoff — the red arm. */
|
|
static int latch_mode = 0;
|
|
|
|
/* One simulated attention layer: the tracker plus the sticky geometry record
|
|
* the shipped code used, so both policies can be driven by the same step loop. */
|
|
typedef struct {
|
|
pn_attn_path_tracker tracker;
|
|
int rings_live; /* the rings are allocated at the current geometry */
|
|
int latch_failed; /* the old `kv_slots_fail_n` != 0 */
|
|
int alloc_attempts; /* how many times an allocation was actually tried */
|
|
} sim_layer;
|
|
|
|
/* Ask the policy under test whether to attempt an allocation this step. */
|
|
static int sim_should_attempt(sim_layer *l)
|
|
{
|
|
if (latch_mode) {
|
|
if (l->latch_failed)
|
|
return 0;
|
|
return 1;
|
|
}
|
|
return pn_attn_ring_should_attempt(&l->tracker);
|
|
}
|
|
|
|
/* Run one decode step against a layer whose rings are missing.
|
|
* `alloc_ok` — would the allocation succeed if attempted this step?
|
|
* `impossible` — if it fails, is the geometry unfittable on an empty die? */
|
|
static void sim_step(sim_layer *l, int alloc_ok, int impossible)
|
|
{
|
|
if (l->rings_live) {
|
|
pn_attn_path_ondie(&l->tracker);
|
|
return;
|
|
}
|
|
if (!sim_should_attempt(l)) {
|
|
pn_attn_path_host(&l->tracker, PN_ATTN_HOST_RING_ALLOC);
|
|
return;
|
|
}
|
|
l->alloc_attempts++;
|
|
if (alloc_ok) {
|
|
l->rings_live = 1;
|
|
l->latch_failed = 0;
|
|
pn_attn_ring_alloc_ok(&l->tracker);
|
|
pn_attn_path_ondie(&l->tracker);
|
|
} else {
|
|
l->latch_failed = 1;
|
|
pn_attn_ring_alloc_failed(&l->tracker, impossible);
|
|
pn_attn_path_host(&l->tracker, PN_ATTN_HOST_RING_ALLOC);
|
|
}
|
|
}
|
|
|
|
/* Drop the rings (a geometry change) without touching the failure budget —
|
|
* what a slot-count or ctx_cap change at the SAME-or-larger geometry does. */
|
|
static void sim_geometry_changed(sim_layer *l)
|
|
{
|
|
l->rings_live = 0;
|
|
}
|
|
|
|
/* A strictly SMALLER geometry: a different question, so both policies get to
|
|
* forget the old verdict. */
|
|
static void sim_geometry_shrank(sim_layer *l)
|
|
{
|
|
l->rings_live = 0;
|
|
l->latch_failed = 0;
|
|
pn_attn_ring_geometry_shrank(&l->tracker);
|
|
}
|
|
|
|
/* --------------------------------------------------------------------- */
|
|
|
|
static void test_transient_recovers(void)
|
|
{
|
|
printf("\n1. a transient ring-allocation failure recovers on a later step\n");
|
|
sim_layer l;
|
|
memset(&l, 0, sizeof(l));
|
|
|
|
sim_step(&l, 0, 0); /* the die is momentarily full */
|
|
CHECK(l.tracker.state == PN_ATTN_PATH_HOST,
|
|
"one failure puts the layer on the host path");
|
|
CHECK(l.tracker.alloc_gave_up == 0,
|
|
"one failure does NOT give up — it is a backoff, not a latch");
|
|
CHECK(l.tracker.reason == PN_ATTN_HOST_RING_ALLOC,
|
|
"the host reason is recorded as ring_alloc_failed");
|
|
|
|
/* The memory frees up. Walk enough steps to clear the first backoff. */
|
|
int recovered = 0;
|
|
for (int step = 0; step < 64 && !recovered; step++) {
|
|
sim_step(&l, 1, 0);
|
|
recovered = l.rings_live;
|
|
}
|
|
CHECK(recovered, "the layer retried and got its rings back");
|
|
CHECK(l.tracker.state == PN_ATTN_PATH_ONDIE,
|
|
"and is reported ON-DIE again");
|
|
CHECK(l.alloc_attempts == 2,
|
|
"exactly one retry was spent, not one per step");
|
|
CHECK(l.tracker.lifetime_alloc_failures == 1,
|
|
"the failure is still on the lifetime counter — what happened, happened");
|
|
}
|
|
|
|
static void test_retry_is_bounded(void)
|
|
{
|
|
printf("\n2. the retry STOPS: a permanently squeezed die costs a fixed budget\n");
|
|
sim_layer l;
|
|
memset(&l, 0, sizeof(l));
|
|
|
|
for (int step = 0; step < 5000; step++)
|
|
sim_step(&l, 0, 0); /* never succeeds, never "impossible" */
|
|
|
|
CHECK(l.alloc_attempts == PN_ATTN_RING_MAX_FAILURES,
|
|
"exactly PN_ATTN_RING_MAX_FAILURES allocations were attempted");
|
|
CHECK(l.tracker.alloc_gave_up == 1, "the layer gave up");
|
|
CHECK(l.tracker.alloc_impossible == 0,
|
|
"gave up on the BUDGET, not because the geometry is impossible");
|
|
CHECK(l.tracker.state == PN_ATTN_PATH_HOST,
|
|
"and still honestly reports the host path");
|
|
|
|
/* 5000 steps at 5 attempts is the whole point: the un-latched version this
|
|
* replaces would have attempted 5000. */
|
|
CHECK(l.alloc_attempts < 10,
|
|
"5000 steps cost fewer than 10 allocation attempts");
|
|
}
|
|
|
|
static void test_impossible_gives_up_at_once(void)
|
|
{
|
|
printf("\n3. an IMPOSSIBLE geometry gives up on the FIRST failure\n");
|
|
sim_layer l;
|
|
memset(&l, 0, sizeof(l));
|
|
|
|
for (int step = 0; step < 5000; step++)
|
|
sim_step(&l, 0, 1); /* would not fit an empty die */
|
|
|
|
CHECK(l.alloc_attempts == 1,
|
|
"one attempt only — there is nothing transient to wait for");
|
|
CHECK(l.tracker.alloc_gave_up == 1, "gave up immediately");
|
|
CHECK(l.tracker.alloc_impossible == 1,
|
|
"and says WHY: the request exceeds the die's usable VRAM");
|
|
}
|
|
|
|
static void test_success_resets_the_budget(void)
|
|
{
|
|
printf("\n4. recurring contention never exhausts the budget\n");
|
|
sim_layer l;
|
|
memset(&l, 0, sizeof(l));
|
|
|
|
for (int round = 0; round < 50; round++) {
|
|
sim_geometry_changed(&l);
|
|
sim_step(&l, 0, 0); /* collide once */
|
|
int recovered = 0;
|
|
for (int step = 0; step < 64 && !recovered; step++) {
|
|
sim_step(&l, 1, 0);
|
|
recovered = l.rings_live;
|
|
}
|
|
if (!recovered) {
|
|
CHECK(0, "round recovered");
|
|
break;
|
|
}
|
|
}
|
|
CHECK(l.rings_live, "still on-die after 50 collide/recover rounds");
|
|
CHECK(l.tracker.alloc_gave_up == 0, "never gave up");
|
|
CHECK(l.tracker.lifetime_alloc_failures == 50,
|
|
"all 50 collisions are counted");
|
|
CHECK(l.alloc_attempts == 100,
|
|
"each round cost exactly one failure plus one success");
|
|
}
|
|
|
|
static void test_shrinking_geometry_recovers_now(void)
|
|
{
|
|
printf("\n5. a SMALLER geometry recovers immediately, with no backoff\n");
|
|
sim_layer l;
|
|
memset(&l, 0, sizeof(l));
|
|
|
|
for (int step = 0; step < 5000; step++)
|
|
sim_step(&l, 0, 1); /* impossible at this geometry: gave up */
|
|
CHECK(l.tracker.alloc_gave_up == 1, "gave up at the large geometry");
|
|
|
|
/* The operator drops max_sessions, or the ladder drops ctx_cap. */
|
|
sim_geometry_shrank(&l);
|
|
int before = l.alloc_attempts;
|
|
sim_step(&l, 1, 0);
|
|
CHECK(l.alloc_attempts == before + 1,
|
|
"the very next step attempts the smaller geometry");
|
|
CHECK(l.rings_live, "and it lands");
|
|
CHECK(l.tracker.state == PN_ATTN_PATH_ONDIE,
|
|
"back on-die without a reload");
|
|
}
|
|
|
|
static void test_residency_gauge(void)
|
|
{
|
|
printf("\n6. the gauge: ondie + host == total, and unreached layers are not a deficit\n");
|
|
pn_attn_path_tracker layers[10];
|
|
memset(layers, 0, sizeof(layers));
|
|
|
|
/* Seven layers ran on-die, two fell back for different reasons, one was
|
|
* never reached by the forward at all. */
|
|
for (int i = 0; i < 7; i++)
|
|
pn_attn_path_ondie(&layers[i]);
|
|
pn_attn_path_host(&layers[7], PN_ATTN_HOST_RING_ALLOC);
|
|
pn_attn_ring_alloc_failed(&layers[7], 1);
|
|
pn_attn_path_host(&layers[8], PN_ATTN_HOST_RING_WRAP);
|
|
/* layers[9] untouched — UNKNOWN */
|
|
|
|
pn_attn_residency r;
|
|
memset(&r, 0, sizeof(r));
|
|
for (int i = 0; i < 10; i++)
|
|
pn_attn_residency_add(&r, &layers[i]);
|
|
|
|
CHECK(r.total == 9, "the layer the forward never reached is not counted");
|
|
CHECK(r.ondie == 7 && r.host == 2, "seven on-die, two on the host path");
|
|
CHECK(r.ondie + r.host == r.total, "the states sum to the total");
|
|
CHECK(r.by_reason[PN_ATTN_HOST_RING_ALLOC] == 1 &&
|
|
r.by_reason[PN_ATTN_HOST_RING_WRAP] == 1,
|
|
"both host reasons are reported separately");
|
|
CHECK(r.gave_up == 1 && r.impossible == 1,
|
|
"the impossible allocation is flagged gave_up AND impossible");
|
|
CHECK(strcmp(pn_attn_host_reason_name(PN_ATTN_HOST_RING_WRAP),
|
|
"kv_geometry_or_ring_wrap") == 0,
|
|
"the reason has a stable name for the JSON");
|
|
|
|
/* A layer that comes back on-die must LEAVE the host count — a gauge that
|
|
* cannot go back down is a log line, not a gauge. */
|
|
pn_attn_path_ondie(&layers[8]);
|
|
memset(&r, 0, sizeof(r));
|
|
for (int i = 0; i < 10; i++)
|
|
pn_attn_residency_add(&r, &layers[i]);
|
|
CHECK(r.ondie == 8 && r.host == 1, "recovery is visible on the gauge");
|
|
CHECK(r.lifetime_host_engagements == 2,
|
|
"but the lifetime engagement count keeps the history");
|
|
}
|
|
|
|
static void test_host_engagements_count_incidents(void)
|
|
{
|
|
printf("\n7. host engagements count INCIDENTS, not tokens\n");
|
|
pn_attn_path_tracker t;
|
|
memset(&t, 0, sizeof(t));
|
|
|
|
for (int step = 0; step < 1000; step++)
|
|
pn_attn_path_host(&t, PN_ATTN_HOST_RING_WRAP);
|
|
CHECK(t.lifetime_host_engagements == 1,
|
|
"1000 steps parked on the host path is ONE engagement");
|
|
|
|
pn_attn_path_ondie(&t);
|
|
pn_attn_path_host(&t, PN_ATTN_HOST_RING_WRAP);
|
|
CHECK(t.lifetime_host_engagements == 2,
|
|
"leaving and re-entering the host path is a second engagement");
|
|
}
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
for (int i = 1; i < argc; i++)
|
|
if (strcmp(argv[i], "--latch") == 0)
|
|
latch_mode = 1;
|
|
|
|
printf("=== attention ring-allocation retry + residency ===\n");
|
|
if (latch_mode)
|
|
printf("MODE: --latch (the OLD sticky behaviour — this arm MUST fail)\n");
|
|
|
|
test_transient_recovers();
|
|
test_retry_is_bounded();
|
|
test_impossible_gives_up_at_once();
|
|
test_success_resets_the_budget();
|
|
test_shrinking_geometry_recovers_now();
|
|
test_residency_gauge();
|
|
test_host_engagements_count_incidents();
|
|
|
|
printf("\n%d check(s) failed\n", failures);
|
|
if (latch_mode) {
|
|
/* Inverted: the red arm exists to be seen failing. */
|
|
printf(failures > 0 ? "[PASS] the latch arm failed, as it must\n"
|
|
: "[FAIL] the latch arm PASSED — the gate is vacuous\n");
|
|
return failures > 0 ? 0 : 1;
|
|
}
|
|
printf(failures == 0 ? "[PASS]\n" : "[FAIL]\n");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|