280 lines
9.7 KiB
C
280 lines
9.7 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/* PC-batched gate (multi-tenant plan Phase 4) — prefix caching in the batched
|
|
* scheduler must be TOKEN-IDENTICAL to a non-cached reference.
|
|
*
|
|
* Setup: 4 concurrent USERS, each a distinct session_id, each running 3 TURNS
|
|
* of a GROWING conversation (a fixed system prompt + the accumulated
|
|
* user/assistant history + a new user line per turn). All 4 users' turns are
|
|
* interleaved through the batch scheduler concurrently. With a session_id the
|
|
* batched path delta-prefills the turn's appended tokens against the session's
|
|
* warm slot (bs_slot_acquire_biased → bs_prefill_resume → bs_slot_finalize);
|
|
* the whole point of the campaign.
|
|
*
|
|
* Correctness bar (argmax-exact, QWEN35_FUSE=0x37): for EVERY user and EVERY
|
|
* turn, the cached batched reply must be byte-identical to a NON-CACHED
|
|
* single-user reference (fresh pn_state, full prefill of the same accumulated
|
|
* conversation). Greedy (temp 0). If any turn diverges the gate FAILS — a
|
|
* silent-drift ship is exactly what this bars.
|
|
*
|
|
* The conversation grows turn over turn so the delta-prefill / warm-cache path
|
|
* is genuinely exercised (turn 2/3 reuse turn 1/2's committed SSM+KV), which is
|
|
* also the realistic steady-state the capacity benchmark measures.
|
|
*
|
|
* Build (CUDA host): make USE_CUDA=1 tests/test_serve_sched_prefix_cache
|
|
* Run: QWEN35_BATCH_SLOTS=1 QWEN35_GPU_SPLIT_LAYER=20 QWEN35_GPU_DIE_A=0 \
|
|
* QWEN35_GPU_DIE_B=1 QWEN35_FUSE=0x37 \
|
|
* ./tests/test_serve_sched_prefix_cache <model.gguf> [max_tokens]
|
|
* NOT part of the CPU test suite. */
|
|
#include "../include/pantheon.h"
|
|
#include "../src/core/engine.h"
|
|
#include "../src/serve/batch_sched.h"
|
|
#include "../src/serve/serve_generation.h"
|
|
#include <pthread.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
#ifndef USE_CUDA
|
|
int main(void)
|
|
{
|
|
fprintf(stderr, "built without USE_CUDA\n");
|
|
return 1;
|
|
}
|
|
#else
|
|
|
|
#define N_USERS 4
|
|
#define N_TURNS 3
|
|
#define SYS_PROMPT "You are a concise, helpful assistant."
|
|
|
|
/* The per-turn user lines for each user's growing conversation. */
|
|
static const char *user_line(int u, int t)
|
|
{
|
|
static const char *lines[N_USERS][N_TURNS] = {
|
|
{ "Name three primary colors.",
|
|
"Now name three secondary colors.",
|
|
"Which of all those is your favorite, and why in one sentence?" },
|
|
{ "What is the capital of France?",
|
|
"What is its most famous landmark?",
|
|
"Roughly how tall is that landmark?" },
|
|
{ "Count from one to five.",
|
|
"Now continue to ten.",
|
|
"What is the sum of all those numbers?" },
|
|
{ "Name a planet in the solar system.",
|
|
"Name the planet just inside its orbit.",
|
|
"Which of the two is larger?" },
|
|
};
|
|
return lines[u][t];
|
|
}
|
|
|
|
/* A conversation: system + accumulated (user/assistant) turns. Grows as
|
|
* replies are appended. Held as an sg_message array. */
|
|
typedef struct {
|
|
sg_message msgs[1 + 2 * N_TURNS]; /* system + up to N_TURNS (user+asst) */
|
|
int n;
|
|
char *owned[1 + 2 * N_TURNS]; /* owned assistant-reply strings to free */
|
|
int n_owned;
|
|
} convo;
|
|
|
|
static void convo_init(convo *c)
|
|
{
|
|
memset(c, 0, sizeof(*c));
|
|
c->msgs[0].role = "system";
|
|
c->msgs[0].content = SYS_PROMPT;
|
|
c->n = 1;
|
|
}
|
|
|
|
static void convo_add_user(convo *c, const char *line)
|
|
{
|
|
c->msgs[c->n].role = "user";
|
|
c->msgs[c->n].content = line;
|
|
c->n++;
|
|
}
|
|
|
|
/* Append an assistant reply (copied, owned by the convo). */
|
|
static void convo_add_assistant(convo *c, const char *reply)
|
|
{
|
|
char *copy = strdup(reply ? reply : "");
|
|
c->owned[c->n_owned++] = copy;
|
|
c->msgs[c->n].role = "assistant";
|
|
c->msgs[c->n].content = copy;
|
|
c->n++;
|
|
}
|
|
|
|
static void convo_free(convo *c)
|
|
{
|
|
for (int i = 0; i < c->n_owned; i++)
|
|
free(c->owned[i]);
|
|
}
|
|
|
|
/* ── Cached batched worker: one user runs all N_TURNS through the scheduler
|
|
* with its session_id, appending each reply so the next turn's prompt grows.
|
|
* The replies are recorded for the gate compare. ── */
|
|
typedef struct {
|
|
pn_model *model;
|
|
pn_batch_sched *sched;
|
|
int user;
|
|
int max_tokens;
|
|
char sid[32];
|
|
char *reply[N_TURNS]; /* owned copies of each turn's content */
|
|
int rc;
|
|
} worker_ctx;
|
|
|
|
static void run_turns(pn_model *model, pn_batch_sched *sched, int user,
|
|
const char *sid, int max_tokens, char *out_reply[N_TURNS],
|
|
int *out_rc)
|
|
{
|
|
convo c;
|
|
convo_init(&c);
|
|
sg_gen_params gp;
|
|
memset(&gp, 0, sizeof(gp));
|
|
gp.temperature = 0.0f; /* greedy — argmax-exact regime */
|
|
gp.top_p = 1.0f;
|
|
gp.top_k = 0;
|
|
gp.max_tokens = max_tokens;
|
|
|
|
for (int t = 0; t < N_TURNS; t++) {
|
|
convo_add_user(&c, user_line(user, t));
|
|
sg_result res;
|
|
memset(&res, 0, sizeof(res));
|
|
int slot = sid ? bs_slot_acquire_biased(sched, sid)
|
|
: bs_slot_acquire(sched);
|
|
if (slot < 0) { *out_rc = -100; convo_free(&c); return; }
|
|
int rc = sg_run_completion_batched(model, sched, slot, c.msgs, c.n,
|
|
&gp, NULL, NULL, &res, sid);
|
|
if (rc != 0) {
|
|
*out_rc = rc;
|
|
sg_result_free(&res);
|
|
convo_free(&c);
|
|
return;
|
|
}
|
|
out_reply[t] = strdup(res.content ? res.content : "");
|
|
convo_add_assistant(&c, res.content ? res.content : "");
|
|
sg_result_free(&res);
|
|
}
|
|
convo_free(&c);
|
|
*out_rc = 0;
|
|
}
|
|
|
|
static void *worker(void *arg)
|
|
{
|
|
worker_ctx *w = arg;
|
|
run_turns(w->model, w->sched, w->user, w->sid, w->max_tokens,
|
|
w->reply, &w->rc);
|
|
return NULL;
|
|
}
|
|
|
|
/* Non-cached single-user reference: the SAME growing conversation, fresh
|
|
* pn_state per turn, full prefill, no session id. Uses sg_run_completion (the
|
|
* established single-user reference path). */
|
|
static int reference_turns(pn_model *model, int user, int max_tokens,
|
|
char *out_reply[N_TURNS])
|
|
{
|
|
convo c;
|
|
convo_init(&c);
|
|
sg_gen_params gp;
|
|
memset(&gp, 0, sizeof(gp));
|
|
gp.temperature = 0.0f;
|
|
gp.top_p = 1.0f;
|
|
gp.top_k = 0;
|
|
gp.max_tokens = max_tokens;
|
|
|
|
for (int t = 0; t < N_TURNS; t++) {
|
|
convo_add_user(&c, user_line(user, t));
|
|
pn_state *st = pn_state_new(model);
|
|
if (!st) { convo_free(&c); return -1; }
|
|
sg_result res;
|
|
memset(&res, 0, sizeof(res));
|
|
int rc = sg_run_completion(model, st, c.msgs, c.n, &gp,
|
|
NULL, NULL, &res, NULL);
|
|
pn_state_free(st);
|
|
if (rc != 0) { sg_result_free(&res); convo_free(&c); return rc; }
|
|
out_reply[t] = strdup(res.content ? res.content : "");
|
|
convo_add_assistant(&c, res.content ? res.content : "");
|
|
sg_result_free(&res);
|
|
}
|
|
convo_free(&c);
|
|
return 0;
|
|
}
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
if (argc < 2) {
|
|
fprintf(stderr, "usage: %s <model.gguf> [max_tokens]\n", argv[0]);
|
|
return 1;
|
|
}
|
|
pn_model *model = pn_load(argv[1]);
|
|
if (!model) { fprintf(stderr, "load failed\n"); return 1; }
|
|
|
|
int max_tokens = argc > 2 ? atoi(argv[2]) : 48;
|
|
if (max_tokens < 4) max_tokens = 4;
|
|
|
|
int failed = 0;
|
|
|
|
/* ── References first (sequential, non-cached, fresh state per turn). ── */
|
|
char *ref[N_USERS][N_TURNS];
|
|
memset(ref, 0, sizeof(ref));
|
|
for (int u = 0; u < N_USERS; u++) {
|
|
if (reference_turns(model, u, max_tokens, ref[u]) != 0) {
|
|
fprintf(stderr, "reference user %d failed\n", u);
|
|
return 1;
|
|
}
|
|
for (int t = 0; t < N_TURNS; t++)
|
|
fprintf(stderr, "ref u=%d t=%d: %.80s\n", u, t, ref[u][t]);
|
|
}
|
|
|
|
/* ── Cached batched run: 4 users concurrent, 3 growing turns each. ── */
|
|
pn_batch_sched *sched = bs_create(model, N_USERS);
|
|
if (!sched) { fprintf(stderr, "bs_create failed\n"); return 1; }
|
|
fprintf(stderr, "\nPC-batched gate: %d users x %d turns, driver_sample=%d\n",
|
|
N_USERS, N_TURNS, bs_driver_sampling(sched) ? 1 : 0);
|
|
|
|
pthread_t th[N_USERS];
|
|
worker_ctx w[N_USERS];
|
|
memset(w, 0, sizeof(w));
|
|
for (int u = 0; u < N_USERS; u++) {
|
|
w[u].model = model; w[u].sched = sched;
|
|
w[u].user = u; w[u].max_tokens = max_tokens;
|
|
snprintf(w[u].sid, sizeof(w[u].sid), "sess-%d", u);
|
|
pthread_create(&th[u], NULL, worker, &w[u]);
|
|
}
|
|
for (int u = 0; u < N_USERS; u++) pthread_join(th[u], NULL);
|
|
|
|
/* ── Compare: every user, every turn, byte-identical to the reference. ── */
|
|
for (int u = 0; u < N_USERS; u++) {
|
|
if (w[u].rc != 0) {
|
|
fprintf(stderr, "cached user %d rc=%d\n", u, w[u].rc);
|
|
failed = 1;
|
|
continue;
|
|
}
|
|
for (int t = 0; t < N_TURNS; t++) {
|
|
const char *g = w[u].reply[t] ? w[u].reply[t] : "";
|
|
const char *r = ref[u][t] ? ref[u][t] : "";
|
|
if (strcmp(g, r) == 0) {
|
|
fprintf(stderr, "PC u=%d t=%d: IDENTICAL (%zu chars)\n",
|
|
u, t, strlen(g));
|
|
} else {
|
|
fprintf(stderr, "PC u=%d t=%d: DIVERGED\n ref: %.160s\n"
|
|
" got: %.160s\n", u, t, r, g);
|
|
failed = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (int u = 0; u < N_USERS; u++)
|
|
for (int t = 0; t < N_TURNS; t++) {
|
|
free(w[u].reply[t]);
|
|
free(ref[u][t]);
|
|
}
|
|
bs_destroy(sched);
|
|
|
|
fprintf(stderr, "\nGATE: %s\n", failed ? "FAIL" :
|
|
"PASS (batched prefix-cached replies ≡ non-cached reference, "
|
|
"argmax-exact across 4 users x 3 growing turns)");
|
|
fflush(stdout); fflush(stderr);
|
|
if (!getenv("QWEN35_EXIT_CLEAN"))
|
|
_Exit(failed);
|
|
return failed;
|
|
}
|
|
#endif
|