Pantheon/tests/test_slot_free_bind_qwen35.c

185 lines
6.8 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/* Lifetime regression gate: bind a decode slot immediately after a session
* free.
*
* THE BUG THIS EXISTS FOR. gpu_attn_layer holds a raw per-slot back-pointer
* into a session's state (kv_slot_kv). pn_qwen35moe_state_free released the
* state without telling the GPU layer, so the NEXT bind into that slot
* dereferenced freed memory in its lazy-mirror "park copy"
* (qwen35moe_forward_cuda.c, pn_qwen35moe_decode_slot_bind_cuda). ASAN caught
* it as a heap-use-after-free; without ASAN the freed 880 bytes read as
* whatever was recycled into them, and the park copy issued a device-to-host
* copy through pointers taken out of the dead struct — the `movntps` fault
* inside libcuda, and the `free(): invalid next size` abort, both reported
* from other harnesses. One bug, several faces.
*
* The serve does exactly this sequence as ordinary operation: batch_sched
* frees a slot's state on every session release / idle reap and binds a new
* session into that slot afterwards.
*
* WHAT IS ASSERTED, and why it is red without the fix even without a
* sanitizer: after pn_state_free, NO decode slot may still hold a
* back-pointer inside the freed state's kv array.
* pn_qwen35moe_gpu_slot_kv_refs_cuda answers that by comparing addresses and
* never dereferencing, so the check itself is legal on a freed range — unlike
* the defect it guards, which read the freed object to decide whether the
* object was freed. Round 2 then performs the real bind-after-free, which is
* where ASAN trips on an unfixed build.
*
* Build (CUDA host): make DEBUG=1 USE_CUDA=1 tests/test_slot_free_bind_qwen35
* Run: QWEN35_GPU_SPLIT_LAYER=20 QWEN35_GPU_DIE_A=6 QWEN35_GPU_DIE_B=7 \
* QWEN35_FUSE=0x37 ./tests/test_slot_free_bind_qwen35 <model.gguf>
* Exit 0 = PASS. NOT part of the CPU test suite (needs the model + a CUDA GPU).
*/
#include "../include/pantheon.h"
#include "../src/core/engine.h"
#include "../src/models/qwen35moe.h"
#include "../src/models/qwen35moe_internal.h"
#include "tensor/tensor.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 PROMPT_LEN 12
#define N_STEPS 4
static int argmax(const float *x, int n)
{
int best = 0;
for (int i = 1; i < n; i++)
if (x[i] > x[best]) best = i;
return best;
}
/* One session's whole life on the batched path: new -> prefill -> bind slot ->
* a few batched decode steps (which is what populates the per-slot
* bookkeeping the bug leaks) -> free. Records the kv-array range of the state
* just before the free so the caller can assert nothing still points into it.
* Returns 0 on success. */
static int session_round(pn_model *model, int n_vocab, int round,
const void **kv_base, size_t *kv_bytes)
{
uint32_t prompt[PROMPT_LEN];
for (int i = 0; i < PROMPT_LEN; i++)
prompt[i] = (uint32_t)((100 + i * 7 + round * 29) % n_vocab);
pn_state *st = pn_state_new(model);
if (!st) {
fprintf(stderr, "round %d: state_new failed\n", round);
return -1;
}
tm_tensor *out = pn_qwen35moe_forward_batch_cuda(model, prompt, PROMPT_LEN,
st, NULL);
if (!out) {
fprintf(stderr, "round %d: prefill failed\n", round);
pn_state_free(st);
return -1;
}
uint32_t tok = (uint32_t)argmax(tm_data_f32(out), n_vocab);
tm_free(out);
/* THE BIND. On round 2 this is the bind-after-free: slot 0 still names
* round 1's (freed) session unless the free path cleared it. */
if (pn_qwen35moe_decode_slot_bind(model, st, 0, 1) != 0) {
fprintf(stderr, "round %d: bind failed\n", round);
pn_state_free(st);
return -1;
}
pn_state *sts[1] = { st };
float *logits[1];
for (int i = 0; i < N_STEPS; i++) {
if (pn_qwen35moe_decode_slots(model, &tok, sts, 1, logits) != 0) {
fprintf(stderr, "round %d: decode_slots failed @step %d\n",
round, i);
pn_state_free(st);
return -1;
}
tok = (uint32_t)argmax(logits[0], n_vocab);
}
/* The range the per-slot back-pointers land in, captured while the state
* is still alive. Only its ADDRESS is used after the free. */
const pn_qwen35moe_state *qs = (const pn_qwen35moe_state *)st->kv_cache;
*kv_base = (const void *)qs->kv;
*kv_bytes = (size_t)qs->n_attn * sizeof(*qs->kv);
pn_state_free(st);
return 0;
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "usage: %s <model.gguf>\n", argv[0]);
return 1;
}
pn_model *model = pn_load(argv[1]);
if (!model) {
fprintf(stderr, "load failed\n");
return 1;
}
int n_vocab = model->config.n_vocab;
int fail = 0;
const void *kv_base = NULL;
size_t kv_bytes = 0;
/* Round 1: a normal session, bound and decoded, then freed. */
if (session_round(model, n_vocab, 1, &kv_base, &kv_bytes) != 0) {
fprintf(stderr, "SLOTFREE: round 1 could not run\n");
fflush(stderr);
_Exit(1);
}
if (!kv_base || !kv_bytes) {
fprintf(stderr, "SLOTFREE: no kv range captured (test is inert)\n");
fflush(stderr);
_Exit(1);
}
/* Assertion 1 — the invariant, checked without touching freed memory. */
int refs = pn_qwen35moe_gpu_slot_kv_refs_cuda(kv_base, kv_bytes);
printf("SLOTFREE dangling_slot_kv_refs=%d\n", refs);
if (refs != 0) {
fprintf(stderr,
"SLOTFREE FAIL: %d decode slot(s) still point into the freed "
"session state\n", refs);
fail = 1;
}
/* Assertion 2 — the real thing: bind slot 0 again right after that free.
* This is the line that faulted (the lazy-mirror park copy). It must
* complete, and under ASAN it must complete without a use-after-free. */
const void *kv_base2 = NULL;
size_t kv_bytes2 = 0;
if (session_round(model, n_vocab, 2, &kv_base2, &kv_bytes2) != 0) {
fprintf(stderr, "SLOTFREE FAIL: bind-after-free round did not run\n");
fail = 1;
} else if (pn_qwen35moe_gpu_slot_kv_refs_cuda(kv_base2, kv_bytes2) != 0) {
fprintf(stderr, "SLOTFREE FAIL: round 2 left a dangling slot ref\n");
fail = 1;
}
printf("SLOTFREE %s\n", fail ? "FAIL" : "PASS");
fflush(stdout);
fflush(stderr);
/* Same teardown convention as the other qwen35moe CUDA harnesses: CUDA
* context teardown wedges on the K80, so exit without unwinding.
* QWEN35_EXIT_CLEAN=1 returns normally instead, which is what lets a
* sanitizer run its end-of-process checks. */
if (!getenv("QWEN35_EXIT_CLEAN"))
_Exit(fail ? 1 : 0);
return fail ? 1 : 0;
}
#endif