621 lines
26 KiB
C
621 lines
26 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_llama_state_snapshot.c
|
|
* @brief Off-hardware gate for the llama-family session snapshot — the
|
|
* primitive session export/restore rides on for every architecture whose
|
|
* per-session state is a plain causal KV cache, which is the family the
|
|
* Tenstorrent backend serves.
|
|
*
|
|
* What it is built to catch, and why each choice is the way it is:
|
|
*
|
|
* - **Byte comparison, never a correlation score.** A layout change scored by
|
|
* PCC is not scored at all (DEV_NOTES §3): PCC is blind to a consistent
|
|
* permutation, and every failure this format can have — wrong stride, wrong
|
|
* layer, wrong head, a dropped row — is a permutation or a truncation.
|
|
* - **The fill carries low mantissa bits.** Small-integer test data makes two
|
|
* deliberately-different builds byte-exact (DEV_NOTES §3); an irrational
|
|
* step does not.
|
|
* - **The per-cell seed is an OFFSET, not a modulus.** Seeding
|
|
* `(element + index * 7) % 16` made cores 1 and 17 byte-identical once. Here
|
|
* every (layer, head, row, col) gets a distinct value by construction, and
|
|
* PAIRWISE DISTINCTNESS is asserted independently of the reference — so a
|
|
* collapse onto one layer's or one head's data cannot be covered for by a
|
|
* reference that is wrong in the same way.
|
|
* - **The destination is POISONED before every restore**, and the rows OUTSIDE
|
|
* [0, pos) are checked to still hold the poison. "It returns the right
|
|
* bytes" does not distinguish a restore from a cache that was already right.
|
|
* - **The other family's blob is fed in on purpose.** The two snapshot formats
|
|
* are told apart by MAGIC; that has to be a check, not a hope.
|
|
*
|
|
* @status Complete. Six mutations proved red — see the plan.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include "../src/models/llama.h"
|
|
#include "../src/core/session_snapshot.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* The qwen35 snapshot unit is linked in only so the dispatcher's OTHER arm is a
|
|
* real call and not a stub — that is what makes "a llama blob is refused by the
|
|
* qwen35 parser" a statement about the shipping parser. Its one external is the
|
|
* forward's content-generation counter, which no test here reaches. */
|
|
unsigned pn_qwen35moe_kv_next_content_gen(void);
|
|
unsigned pn_qwen35moe_kv_next_content_gen(void) { return 1u; }
|
|
|
|
static int checks = 0;
|
|
static int failures = 0;
|
|
|
|
#define CHECK(cond, ...) \
|
|
do { \
|
|
checks++; \
|
|
if (!(cond)) { \
|
|
failures++; \
|
|
printf(" FAIL: "); \
|
|
printf(__VA_ARGS__); \
|
|
printf(" (%s:%d)\n", __FILE__, __LINE__); \
|
|
} \
|
|
} while (0)
|
|
|
|
/* ============================================================
|
|
* A hand-built KV cache. Deliberately NOT pn_llama_kv_cache_new(): that lives
|
|
* in llama.c and would drag the whole model in, and this file must stay a
|
|
* host-only unit with no engine and no accelerator.
|
|
* ============================================================ */
|
|
|
|
#define POISON 12345.5f
|
|
|
|
static tm_tensor *make_kv_tensor(int n_kv_head, int max_seq, int head_dim)
|
|
{
|
|
tm_tensor *t = calloc(1, sizeof(*t));
|
|
if (!t)
|
|
return NULL;
|
|
t->ndim = 3;
|
|
t->shape[0] = n_kv_head;
|
|
t->shape[1] = max_seq;
|
|
t->shape[2] = head_dim;
|
|
t->numel = (int64_t)n_kv_head * max_seq * head_dim;
|
|
t->dtype = TM_F32;
|
|
t->data = malloc((size_t)t->numel * sizeof(float));
|
|
if (!t->data) { free(t); return NULL; }
|
|
t->owns_data = true;
|
|
return t;
|
|
}
|
|
|
|
static pn_llama_kv_cache *make_cache(int n_layers, int n_kv_head, int max_seq,
|
|
int head_dim)
|
|
{
|
|
pn_llama_kv_cache *c = calloc(1, sizeof(*c));
|
|
if (!c)
|
|
return NULL;
|
|
c->n_layers = n_layers;
|
|
c->max_seq = max_seq;
|
|
c->k_cache = calloc((size_t)n_layers, sizeof(tm_tensor *));
|
|
c->v_cache = calloc((size_t)n_layers, sizeof(tm_tensor *));
|
|
if (!c->k_cache || !c->v_cache)
|
|
return NULL;
|
|
for (int i = 0; i < n_layers; i++) {
|
|
c->k_cache[i] = make_kv_tensor(n_kv_head, max_seq, head_dim);
|
|
c->v_cache[i] = make_kv_tensor(n_kv_head, max_seq, head_dim);
|
|
if (!c->k_cache[i] || !c->v_cache[i])
|
|
return NULL;
|
|
}
|
|
return c;
|
|
}
|
|
|
|
static void free_cache(pn_llama_kv_cache *c)
|
|
{
|
|
if (!c)
|
|
return;
|
|
for (int i = 0; i < c->n_layers; i++) {
|
|
if (c->k_cache && c->k_cache[i]) { free(c->k_cache[i]->data); free(c->k_cache[i]); }
|
|
if (c->v_cache && c->v_cache[i]) { free(c->v_cache[i]->data); free(c->v_cache[i]); }
|
|
}
|
|
free(c->k_cache);
|
|
free(c->v_cache);
|
|
free(c);
|
|
}
|
|
|
|
/* A value unique to (kv, layer, head, row, col), carrying low mantissa bits.
|
|
* The identifying part is an OFFSET on a base with no modulus anywhere, so no
|
|
* two cells of the whole cache can collide. */
|
|
static float cell_value(int kv, int layer, int head, int row, int col,
|
|
int n_kv_head, int max_seq, int head_dim)
|
|
{
|
|
long idx = (((long)kv * 64 + layer) * n_kv_head + head);
|
|
idx = (idx * max_seq + row) * head_dim + col;
|
|
return (float)idx * 0.31830988618379f + 0.0009765625f * (float)(idx & 1023);
|
|
}
|
|
|
|
static void fill_cache(pn_llama_kv_cache *c, int n_kv_head, int max_seq,
|
|
int head_dim)
|
|
{
|
|
for (int l = 0; l < c->n_layers; l++) {
|
|
for (int kv = 0; kv < 2; kv++) {
|
|
float *d = (float *)(kv ? c->v_cache[l]->data : c->k_cache[l]->data);
|
|
for (int h = 0; h < n_kv_head; h++)
|
|
for (int r = 0; r < max_seq; r++)
|
|
for (int col = 0; col < head_dim; col++)
|
|
d[((size_t)h * max_seq + r) * head_dim + col] =
|
|
cell_value(kv, l, h, r, col, n_kv_head, max_seq, head_dim);
|
|
}
|
|
}
|
|
}
|
|
|
|
static void poison_cache(pn_llama_kv_cache *c, int n_kv_head, int max_seq,
|
|
int head_dim)
|
|
{
|
|
size_t n = (size_t)n_kv_head * max_seq * head_dim;
|
|
for (int l = 0; l < c->n_layers; l++) {
|
|
float *k = (float *)c->k_cache[l]->data;
|
|
float *v = (float *)c->v_cache[l]->data;
|
|
for (size_t i = 0; i < n; i++) { k[i] = POISON; v[i] = POISON; }
|
|
}
|
|
}
|
|
|
|
/* ============================================================
|
|
* Stage 1 — capture / restore round trip, byte-exact
|
|
* ============================================================ */
|
|
|
|
static void test_capture_restore(void)
|
|
{
|
|
const int L = 4, H = 3, S = 16, D = 8, POS = 11;
|
|
printf("--- capture/restore round trip (L=%d H=%d S=%d D=%d pos=%d) ---\n",
|
|
L, H, S, D, POS);
|
|
|
|
pn_llama_kv_cache *src = make_cache(L, H, S, D);
|
|
pn_llama_kv_cache *dst = make_cache(L, H, S, D);
|
|
CHECK(src && dst, "caches allocated");
|
|
if (!src || !dst) return;
|
|
fill_cache(src, H, S, D);
|
|
poison_cache(dst, H, S, D);
|
|
|
|
void *snap = pn_llama_kv_snapshot(src, POS);
|
|
CHECK(snap != NULL, "capture at pos=%d succeeded", POS);
|
|
CHECK(pn_llama_kv_snapshot_pos(snap) == POS, "snapshot reports pos=%d", POS);
|
|
CHECK(pn_llama_kv_restore(dst, snap) == 0, "restore into a poisoned cache");
|
|
|
|
/* Rows [0, POS) must be byte-identical to the source; rows [POS, S) must
|
|
* still be poison — a restore that writes the whole ring, or one that
|
|
* strides by max_seq instead of pos, fails one of the two. */
|
|
int rows_in = 0, rows_out = 0;
|
|
for (int l = 0; l < L; l++) {
|
|
for (int kv = 0; kv < 2; kv++) {
|
|
const float *s = (const float *)(kv ? src->v_cache[l]->data : src->k_cache[l]->data);
|
|
const float *d = (const float *)(kv ? dst->v_cache[l]->data : dst->k_cache[l]->data);
|
|
for (int h = 0; h < H; h++) {
|
|
const float *sr = s + (size_t)h * S * D;
|
|
const float *dr = d + (size_t)h * S * D;
|
|
if (memcmp(sr, dr, (size_t)POS * D * sizeof(float)) != 0)
|
|
rows_in++;
|
|
for (int r = POS; r < S; r++)
|
|
for (int col = 0; col < D; col++)
|
|
if (dr[(size_t)r * D + col] != POISON)
|
|
rows_out++;
|
|
}
|
|
}
|
|
}
|
|
CHECK(rows_in == 0, "every (layer,kv,head) rows [0,%d) byte-identical "
|
|
"(%d differed)", POS, rows_in);
|
|
CHECK(rows_out == 0, "rows [%d,%d) still poison (%d overwritten)",
|
|
POS, S, rows_out);
|
|
|
|
/* Pairwise distinctness — independent of the reference. If the capture
|
|
* collapsed every layer onto layer 0 (or every head onto head 0) the
|
|
* comparison above would still pass whenever the reference were wrong the
|
|
* same way; this cannot be. */
|
|
int dup = 0;
|
|
for (int l = 0; l < L; l++)
|
|
for (int h = 0; h < H; h++) {
|
|
const float *a = (const float *)dst->k_cache[l]->data + (size_t)h * S * D;
|
|
for (int l2 = 0; l2 < L; l2++)
|
|
for (int h2 = 0; h2 < H; h2++) {
|
|
if (l2 < l || (l2 == l && h2 <= h)) continue;
|
|
const float *b = (const float *)dst->k_cache[l2]->data + (size_t)h2 * S * D;
|
|
if (memcmp(a, b, (size_t)POS * D * sizeof(float)) == 0)
|
|
dup++;
|
|
}
|
|
}
|
|
CHECK(dup == 0, "all %d (layer,head) restored panels differ from each other "
|
|
"(%d collisions)", L * H, dup);
|
|
|
|
/* Refusals that protect the ring. */
|
|
CHECK(pn_llama_kv_snapshot(src, 0) == NULL, "capture at pos=0 refused");
|
|
CHECK(pn_llama_kv_snapshot(src, -1) == NULL, "capture at pos=-1 refused");
|
|
CHECK(pn_llama_kv_snapshot(src, S) != NULL, "capture at pos==max_seq allowed");
|
|
void *over = pn_llama_kv_snapshot(src, S + 1);
|
|
CHECK(over == NULL, "capture at pos>max_seq refused (the cache is a RING)");
|
|
pn_llama_kv_snapshot_free(over);
|
|
|
|
/* Restore into a cache of a different shape must refuse, not truncate. */
|
|
pn_llama_kv_cache *narrow = make_cache(L, H, S, D / 2);
|
|
pn_llama_kv_cache *shallow = make_cache(L - 1, H, S, D);
|
|
pn_llama_kv_cache *fewheads = make_cache(L, H - 1, S, D);
|
|
CHECK(pn_llama_kv_restore(narrow, snap) != 0, "restore refuses a head_dim mismatch");
|
|
CHECK(pn_llama_kv_restore(shallow, snap) != 0, "restore refuses an n_layers mismatch");
|
|
CHECK(pn_llama_kv_restore(fewheads, snap) != 0, "restore refuses an n_kv_head mismatch");
|
|
|
|
/* A SHORTER ring must refuse: `pos` arrives inside a client-held blob, and
|
|
* writing pos rows into a ring of max_seq < pos walks off the end of the
|
|
* live cache. This is the one bound that is a property of the DESTINATION
|
|
* and not of the blob, so nothing the parser checks can cover it. */
|
|
pn_llama_kv_cache *shortring = make_cache(L, H, POS - 1, D);
|
|
CHECK(pn_llama_kv_restore(shortring, snap) != 0,
|
|
"restore REFUSES a ring shorter than the snapshot's pos");
|
|
free_cache(shortring);
|
|
|
|
/* A LONGER ring is fine — the rows are repacked on the way in. */
|
|
pn_llama_kv_cache *longer = make_cache(L, H, S * 2, D);
|
|
poison_cache(longer, H, S * 2, D);
|
|
CHECK(pn_llama_kv_restore(longer, snap) == 0, "restore accepts a longer ring");
|
|
int diff_long = 0;
|
|
for (int l = 0; l < L; l++)
|
|
for (int h = 0; h < H; h++) {
|
|
const float *s = (const float *)src->k_cache[l]->data + (size_t)h * S * D;
|
|
const float *d = (const float *)longer->k_cache[l]->data + (size_t)h * (S * 2) * D;
|
|
if (memcmp(s, d, (size_t)POS * D * sizeof(float)) != 0)
|
|
diff_long++;
|
|
}
|
|
CHECK(diff_long == 0, "longer-ring restore is byte-identical over [0,%d)", POS);
|
|
|
|
free_cache(narrow); free_cache(shallow); free_cache(fewheads); free_cache(longer);
|
|
pn_llama_kv_snapshot_free(snap);
|
|
free_cache(src);
|
|
free_cache(dst);
|
|
}
|
|
|
|
/* ============================================================
|
|
* Stage 2 — serialize / deserialize
|
|
* ============================================================ */
|
|
|
|
static void test_serialize(void)
|
|
{
|
|
const int L = 3, H = 2, S = 12, D = 8, POS = 7;
|
|
const uint64_t WV = 0xA1B2C3D4E5F60718ull;
|
|
printf("--- serialize / deserialize ---\n");
|
|
|
|
pn_llama_kv_cache *src = make_cache(L, H, S, D);
|
|
CHECK(src != NULL, "cache allocated");
|
|
if (!src) return;
|
|
fill_cache(src, H, S, D);
|
|
|
|
void *snap = pn_llama_kv_snapshot(src, POS);
|
|
CHECK(snap != NULL, "capture");
|
|
|
|
unsigned char *b1 = NULL, *b2 = NULL;
|
|
size_t l1 = 0, l2 = 0;
|
|
CHECK(pn_llama_kv_snap_serialize(snap, WV, &b1, &l1) == 0, "serialize");
|
|
size_t expect = 36 + (size_t)L * 2 * (size_t)H * POS * D * sizeof(float);
|
|
CHECK(l1 == expect, "blob is %zu bytes (header 36 + payload), got %zu",
|
|
expect, l1);
|
|
|
|
uint64_t wv_out = 0;
|
|
void *snap2 = pn_llama_kv_snap_deserialize(b1, l1, &wv_out);
|
|
CHECK(snap2 != NULL, "deserialize");
|
|
CHECK(wv_out == WV, "weights_version round-trips");
|
|
CHECK(pn_llama_kv_snapshot_pos(snap2) == POS, "pos round-trips");
|
|
|
|
CHECK(pn_llama_kv_snap_serialize(snap2, WV, &b2, &l2) == 0, "re-serialize");
|
|
CHECK(l1 == l2 && b1 && b2 && memcmp(b1, b2, l1) == 0,
|
|
"serialize -> deserialize -> serialize is BYTE-IDENTICAL");
|
|
|
|
/* And the deserialized snapshot restores the same bytes the live one does. */
|
|
pn_llama_kv_cache *dst = make_cache(L, H, S, D);
|
|
poison_cache(dst, H, S, D);
|
|
CHECK(pn_llama_kv_restore(dst, snap2) == 0, "restore from the parsed blob");
|
|
int diff = 0;
|
|
for (int l = 0; l < L; l++)
|
|
for (int kv = 0; kv < 2; kv++) {
|
|
const float *s = (const float *)(kv ? src->v_cache[l]->data : src->k_cache[l]->data);
|
|
const float *d = (const float *)(kv ? dst->v_cache[l]->data : dst->k_cache[l]->data);
|
|
for (int h = 0; h < H; h++)
|
|
if (memcmp(s + (size_t)h * S * D, d + (size_t)h * S * D,
|
|
(size_t)POS * D * sizeof(float)) != 0)
|
|
diff++;
|
|
}
|
|
CHECK(diff == 0, "blob round-trip restores byte-identical rows (%d differed)",
|
|
diff);
|
|
|
|
/* --- hostile / malformed blobs --- */
|
|
unsigned char *bad = malloc(l1);
|
|
CHECK(bad != NULL, "scratch");
|
|
if (bad) {
|
|
memcpy(bad, b1, l1);
|
|
bad[0] ^= 0xFF;
|
|
CHECK(pn_llama_kv_snap_deserialize(bad, l1, NULL) == NULL,
|
|
"corrupt magic refused");
|
|
memcpy(bad, b1, l1);
|
|
bad[4] = 0x02; /* format_version = 2 */
|
|
CHECK(pn_llama_kv_snap_deserialize(bad, l1, NULL) == NULL,
|
|
"unknown format_version refused");
|
|
memcpy(bad, b1, l1);
|
|
bad[16] = 0xFF; bad[17] = 0xFF; bad[18] = 0xFF; bad[19] = 0x7F; /* pos huge */
|
|
CHECK(pn_llama_kv_snap_deserialize(bad, l1, NULL) == NULL,
|
|
"absurd pos refused");
|
|
memcpy(bad, b1, l1);
|
|
bad[16] = 0; bad[17] = 0; bad[18] = 0; bad[19] = 0; /* pos = 0 */
|
|
CHECK(pn_llama_kv_snap_deserialize(bad, l1, NULL) == NULL,
|
|
"pos=0 refused");
|
|
free(bad);
|
|
}
|
|
CHECK(pn_llama_kv_snap_deserialize(b1, l1 - 1, NULL) == NULL,
|
|
"truncated blob refused");
|
|
CHECK(pn_llama_kv_snap_deserialize(b1, 8, NULL) == NULL,
|
|
"sub-header blob refused");
|
|
CHECK(pn_llama_kv_snap_deserialize(NULL, l1, NULL) == NULL, "NULL refused");
|
|
|
|
unsigned char *longer = malloc(l1 + 4);
|
|
if (longer) {
|
|
memcpy(longer, b1, l1);
|
|
memset(longer + l1, 0, 4);
|
|
CHECK(pn_llama_kv_snap_deserialize(longer, l1 + 4, NULL) == NULL,
|
|
"TRAILING GARBAGE refused (a valid blob is consumed exactly)");
|
|
free(longer);
|
|
}
|
|
|
|
/* The other family's blob: "PNS1" must not parse as "PNLK". Built by hand
|
|
* so this test needs no qwen35 state to produce one. */
|
|
unsigned char other[64];
|
|
memcpy(other, b1, sizeof(other) < l1 ? sizeof(other) : l1);
|
|
other[0] = 'P'; other[1] = 'N'; other[2] = 'S'; other[3] = '1';
|
|
CHECK(pn_llama_kv_snap_deserialize(other, sizeof(other), NULL) == NULL,
|
|
"a qwen35-family 'PNS1' blob is refused by the llama parser");
|
|
|
|
pn_llama_kv_snapshot_free(snap);
|
|
pn_llama_kv_snapshot_free(snap2);
|
|
free(b1);
|
|
free(b2);
|
|
free_cache(src);
|
|
free_cache(dst);
|
|
}
|
|
|
|
/* ============================================================
|
|
* Stage 3 — the arch dispatcher routes by name and refuses the rest
|
|
* ============================================================ */
|
|
|
|
static void test_dispatcher(void)
|
|
{
|
|
const int L = 2, H = 2, S = 8, D = 4, POS = 5;
|
|
printf("--- session_snapshot dispatcher ---\n");
|
|
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_LLAMA), "llama supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_QWEN2), "qwen2 supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_QWEN3), "qwen3 supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_PHI), "phi supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_MISTRAL), "mistral supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_MIXTRAL), "mixtral supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_QWEN35MOE), "qwen35moe supported");
|
|
CHECK(pn_session_snapshot_supported(PN_ARCH_BONSAI), "bonsai supported");
|
|
CHECK(!pn_session_snapshot_supported(PN_ARCH_RWKVX),
|
|
"RWKVX REFUSED — a recurrent state is not a KV cache");
|
|
CHECK(!pn_session_snapshot_supported(PN_ARCH_UNKNOWN), "unknown refused");
|
|
|
|
pn_llama_kv_cache *src = make_cache(L, H, S, D);
|
|
pn_llama_kv_cache *dst = make_cache(L, H, S, D);
|
|
CHECK(src && dst, "caches allocated");
|
|
if (!src || !dst) return;
|
|
fill_cache(src, H, S, D);
|
|
poison_cache(dst, H, S, D);
|
|
|
|
void *snap = pn_session_snapshot_capture(PN_ARCH_LLAMA, src, POS);
|
|
CHECK(snap != NULL, "dispatcher captured a llama session");
|
|
CHECK(pn_session_snapshot_pos(PN_ARCH_LLAMA, snap) == POS,
|
|
"dispatcher reports pos");
|
|
CHECK(pn_session_snapshot_restore(PN_ARCH_LLAMA, dst, snap) == 0,
|
|
"dispatcher restored it");
|
|
int diff = 0;
|
|
for (int l = 0; l < L; l++)
|
|
for (int h = 0; h < H; h++)
|
|
if (memcmp((const float *)src->k_cache[l]->data + (size_t)h * S * D,
|
|
(const float *)dst->k_cache[l]->data + (size_t)h * S * D,
|
|
(size_t)POS * D * sizeof(float)) != 0)
|
|
diff++;
|
|
CHECK(diff == 0, "dispatcher round trip is byte-identical");
|
|
|
|
unsigned char *blob = NULL;
|
|
size_t blen = 0;
|
|
CHECK(pn_session_snapshot_serialize(PN_ARCH_LLAMA, snap, 7ull, &blob, &blen) == 0,
|
|
"dispatcher serialized");
|
|
uint64_t wv = 0;
|
|
void *reparsed = pn_session_snapshot_deserialize(PN_ARCH_LLAMA, blob, blen, &wv);
|
|
CHECK(reparsed != NULL && wv == 7ull, "dispatcher deserialized");
|
|
|
|
/* A llama blob offered to the qwen35 arm must be refused by magic, and an
|
|
* unsupported arch must refuse everything rather than guess a family. */
|
|
CHECK(pn_session_snapshot_deserialize(PN_ARCH_QWEN35MOE, blob, blen, NULL) == NULL,
|
|
"a llama 'PNLK' blob is refused by the qwen35 parser");
|
|
CHECK(pn_session_snapshot_capture(PN_ARCH_RWKVX, src, POS) == NULL,
|
|
"capture on an unsupported arch refused");
|
|
CHECK(pn_session_snapshot_restore(PN_ARCH_RWKVX, dst, snap) != 0,
|
|
"restore on an unsupported arch refused");
|
|
CHECK(pn_session_snapshot_deserialize(PN_ARCH_RWKVX, blob, blen, NULL) == NULL,
|
|
"deserialize on an unsupported arch refused");
|
|
|
|
pn_session_snapshot_free(PN_ARCH_LLAMA, reparsed);
|
|
pn_session_snapshot_free(PN_ARCH_LLAMA, snap);
|
|
pn_session_snapshot_free(PN_ARCH_RWKVX, NULL); /* NULL-safe on every arm */
|
|
free(blob);
|
|
free_cache(src);
|
|
free_cache(dst);
|
|
}
|
|
|
|
/* ============================================================
|
|
* Stage 4 — the DEVICE-RESIDENT cache, through the kv_move hook
|
|
*
|
|
* When the Tenstorrent backend runs attention on the card, the KV cache is card DRAM in bf16
|
|
* and `k_cache`/`v_cache` are NEVER WRITTEN. A snapshot that read them would export a page of
|
|
* zeros and call it a session, so `pn_llama_kv_cache::kv_move` redirects the rows to the
|
|
* device.
|
|
*
|
|
* A fake device stands in for the card, and that is the point of making the hook a function
|
|
* pointer rather than an `#ifdef`: this branch is covered by a test that can go red on any
|
|
* machine, instead of by code no off-hardware suite ever compiles.
|
|
*
|
|
* The fake stores bf16, because the card does. That is what makes the round-trip check a
|
|
* claim about the real system: bf16 is a strict subset of fp32, so widening on the way out
|
|
* and narrowing on the way back is EXACT and the blob format needs no change.
|
|
* ============================================================ */
|
|
|
|
/* Round to bf16 the way the card's cache does. Truncation rather than round-to-nearest — the
|
|
* direction does not matter here, only that a value which has BEEN through it survives a
|
|
* second pass unchanged, which is the property the round trip rests on. */
|
|
static float to_bf16(float v)
|
|
{
|
|
uint32_t bits;
|
|
memcpy(&bits, &v, sizeof(bits));
|
|
bits &= 0xFFFF0000u;
|
|
memcpy(&v, &bits, sizeof(v));
|
|
return v;
|
|
}
|
|
|
|
typedef struct {
|
|
int n_layers, n_kv_head, capacity, head_dim;
|
|
float *rows; /* [layer][kv][head][position][dim], bf16-rounded */
|
|
int fail_after; /* transfers to allow before failing; -1 = never fail */
|
|
int moves;
|
|
} fake_device;
|
|
|
|
static float *fake_slot(fake_device *d, int layer, int values)
|
|
{
|
|
size_t per = (size_t)d->n_kv_head * d->capacity * d->head_dim;
|
|
return d->rows + ((size_t)layer * 2 + values) * per;
|
|
}
|
|
|
|
static int fake_move(void *device, int layer, int values, int position_count, float *rows,
|
|
int capture)
|
|
{
|
|
fake_device *d = (fake_device *)device;
|
|
float *slot = fake_slot(d, layer, values);
|
|
|
|
if (d->fail_after >= 0 && d->moves >= d->fail_after)
|
|
return -1;
|
|
d->moves++;
|
|
for (int h = 0; h < d->n_kv_head; h++) {
|
|
for (int p = 0; p < position_count; p++) {
|
|
float *host = rows + ((size_t)h * position_count + p) * d->head_dim;
|
|
float *dev = slot + ((size_t)h * d->capacity + p) * d->head_dim;
|
|
|
|
for (int e = 0; e < d->head_dim; e++) {
|
|
if (capture)
|
|
host[e] = dev[e];
|
|
else
|
|
dev[e] = to_bf16(host[e]);
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static void test_device_resident_cache(void)
|
|
{
|
|
const int L = 4, H = 3, S = 16, D = 8, POS = 11;
|
|
printf("--- device-resident cache via kv_move (L=%d H=%d S=%d D=%d pos=%d) ---\n",
|
|
L, H, S, D, POS);
|
|
|
|
pn_llama_kv_cache *src = make_cache(L, H, S, D);
|
|
pn_llama_kv_cache *dst = make_cache(L, H, S, D);
|
|
fake_device from = { L, H, S, D, NULL, -1, 0 };
|
|
fake_device into = { L, H, S, D, NULL, -1, 0 };
|
|
size_t cells = (size_t)L * 2 * H * S * D;
|
|
int wrong = 0, host_touched = 0;
|
|
void *snap;
|
|
|
|
from.rows = calloc(cells, sizeof(float));
|
|
into.rows = calloc(cells, sizeof(float));
|
|
CHECK(src && dst && from.rows && into.rows, "caches and fake devices allocated");
|
|
if (!src || !dst || !from.rows || !into.rows)
|
|
return;
|
|
|
|
/* The DEVICE holds the real state, bf16-rounded; the host tensors hold poison. If the
|
|
* snapshot ever reads the tensors instead of the device, every value it carries is POISON
|
|
* and the comparison below cannot pass by accident. */
|
|
for (int l = 0; l < L; l++) {
|
|
for (int kv = 0; kv < 2; kv++) {
|
|
float *slot = fake_slot(&from, l, kv);
|
|
|
|
for (int h = 0; h < H; h++)
|
|
for (int r = 0; r < S; r++)
|
|
for (int c = 0; c < D; c++)
|
|
slot[((size_t)h * S + r) * D + c] =
|
|
to_bf16(cell_value(kv, l, h, r, c, H, S, D));
|
|
}
|
|
}
|
|
poison_cache(src, H, S, D);
|
|
poison_cache(dst, H, S, D);
|
|
src->kv_device = &from;
|
|
src->kv_move = fake_move;
|
|
dst->kv_device = &into;
|
|
dst->kv_move = fake_move;
|
|
|
|
snap = pn_llama_kv_snapshot(src, POS);
|
|
CHECK(snap != NULL, "capture from a device-resident cache");
|
|
CHECK(from.moves == L * 2, "capture asked the DEVICE for all %d (layer,kv) panels rather "
|
|
"than reading the host tensors (%d moves)", L * 2, from.moves);
|
|
CHECK(pn_llama_kv_restore(dst, snap) == 0, "restore into a device-resident cache");
|
|
|
|
for (int l = 0; l < L; l++) {
|
|
for (int kv = 0; kv < 2; kv++) {
|
|
const float *a = fake_slot(&from, l, kv);
|
|
const float *b = fake_slot(&into, l, kv);
|
|
const float *tensor =
|
|
(const float *)(kv ? dst->v_cache[l]->data : dst->k_cache[l]->data);
|
|
|
|
for (int h = 0; h < H; h++)
|
|
for (int r = 0; r < POS; r++)
|
|
for (int c = 0; c < D; c++) {
|
|
size_t i = ((size_t)h * S + r) * D + c;
|
|
|
|
if (a[i] != b[i])
|
|
wrong++;
|
|
}
|
|
for (size_t i = 0; i < (size_t)H * S * D; i++)
|
|
if (tensor[i] != POISON)
|
|
host_touched++;
|
|
}
|
|
}
|
|
CHECK(wrong == 0, "device -> blob -> device is byte-identical over [0,%d) (%d wrong)",
|
|
POS, wrong);
|
|
CHECK(host_touched == 0, "the HOST tensors were never written on the device arm "
|
|
"(%d cells touched)", host_touched);
|
|
|
|
/* A FAILED transfer must not be absorbed: a snapshot that keeps whatever the buffer held
|
|
* is the zero-page export wearing a different hat. */
|
|
{
|
|
fake_device broken = { L, H, S, D, from.rows, 2, 0 };
|
|
pn_llama_kv_cache *flaky = make_cache(L, H, S, D);
|
|
|
|
CHECK(flaky != NULL, "flaky cache allocated");
|
|
if (flaky) {
|
|
flaky->kv_device = &broken;
|
|
flaky->kv_move = fake_move;
|
|
CHECK(pn_llama_kv_snapshot(flaky, POS) == NULL,
|
|
"capture refuses when a device transfer fails");
|
|
broken.moves = 0;
|
|
CHECK(pn_llama_kv_restore(flaky, snap) != 0,
|
|
"restore refuses when a device transfer fails");
|
|
free_cache(flaky);
|
|
}
|
|
}
|
|
|
|
pn_llama_kv_snapshot_free(snap);
|
|
free(from.rows);
|
|
free(into.rows);
|
|
free_cache(src);
|
|
free_cache(dst);
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
printf("=== llama session snapshot ===\n");
|
|
test_capture_restore();
|
|
test_serialize();
|
|
test_dispatcher();
|
|
test_device_resident_cache();
|
|
printf("=== %d checks, %d failures ===\n", checks, failures);
|
|
return failures ? 1 : 0;
|
|
}
|