From cbd599024e6dabddf75a29630faa0a577731d4be Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 13 Jul 2026 20:30:36 +0800 Subject: [PATCH] Unify continuous batching + heterogeneous runtime: decode batching, physical-core planning, disjoint VRAM/RAM placement, topp-policy warning (CPU-validated, CUDA on 6x5090) (#68) * Fuse CUDA expert MLP execution * Group CUDA expert transfers by device * Instrument grouped CUDA expert execution * Bound grouped CUDA decode scratch * Execute expert groups across GPUs in parallel * Release host backing for multi-GPU experts * Define quality-preserving memory policies * Overlap cold expert loading with resident compute * Adapt expert placement with session LFRU * Fuse q4 expert gate and up dispatch * Plan CPU work on physical cores * Batch grouped expert CUDA kernels * Separate VRAM and RAM expert placement * Add ragged multi-sequence decode forward * feat(runtime): add continuous decode scheduler * Route concurrent API requests through batch scheduler * Harden multiplex request lifecycle and framing * Cancel disconnected multiplex requests * Bind API port before starting the engine * fix automatic KV slot allocation * add native int4 Tensor Core grouped GEMM * add Tensor Core throughput benchmark * optimize packed int4 low-row kernels * add asynchronous CUDA staging streams * document validated six-GPU dense acceleration * tune six-GPU expert hot set * raise validated expert hot-set target * add CUDA MLA absorption core * fuse grouped expert gate and up projections * Warn for explicit lossy routing flags --- README.md | 98 +++++- c/Makefile | 14 +- c/backend_cuda.cu | 347 ++++++++++++++++++++- c/backend_cuda.h | 21 ++ c/coli | 8 +- c/decode_batch.h | 37 +++ c/glm.c | 570 +++++++++++++++++++++++++++++----- c/openai_server.py | 220 ++++++++++--- c/resource_plan.py | 82 ++++- c/tests/bench_tensor_core.cu | 45 +++ c/tests/test_backend_cuda.cu | 74 ++++- c/tests/test_decode_batch | Bin 0 -> 16448 bytes c/tests/test_decode_batch.c | 56 ++++ c/tests/test_doctor.py | 2 +- c/tests/test_i4_acc512 | Bin 0 -> 16200 bytes c/tests/test_idot | Bin 0 -> 239944 bytes c/tests/test_openai_server.py | 245 ++++++++++++++- c/tests/test_resource_plan.py | 46 ++- c/tests/test_tier.c | 5 + c/tier.h | 29 ++ 20 files changed, 1741 insertions(+), 158 deletions(-) create mode 100644 c/decode_batch.h create mode 100644 c/tests/bench_tensor_core.cu create mode 100755 c/tests/test_decode_batch create mode 100644 c/tests/test_decode_batch.c create mode 100755 c/tests/test_i4_acc512 create mode 100755 c/tests/test_idot diff --git a/README.md b/README.md index 11696de..e5671ea 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ **Tiny engine, immense model.** Run **GLM-5.2 (744B-parameter MoE)** on a consumer machine with ~25 GB of RAM — in pure C, with zero dependencies, by streaming experts from disk. +Colibrì is a lightweight, quality-preserving MoE runtime that treats VRAM, +RAM, and storage as one managed memory hierarchy. Insufficient fast memory may +reduce speed, but the default policy never silently changes model precision or +router semantics. + ``` $ ./coli chat 🐦 colibrì v1.0 — GLM-5.2 · 744B MoE · int4 · streaming CPU @@ -285,9 +290,13 @@ cross-compiling. Requesting CUDA with a CPU-only binary, an invalid device, or an unavailable runtime fails at startup instead of silently falling back. The normal `make` build and runtime behavior are unchanged. CUDA defaults to an -expert-only accelerator: resident dense/attention tensors stay on CPU because -fixture measurements show that moving them does not help while expert I/O is -the bottleneck. `CUDA_DENSE=1` keeps the earlier all-resident experimental path. +expert-only accelerator. `CUDA_DENSE=1` additionally distributes resident +dense/attention projection tensors round-robin across the selected devices; +their projected footprint is reserved before the expert tier is placed. On six +RTX 5090s with a 150 GB expert tier, a warmed two-request/64-token GLM-5.2 run +improved from 1.650 to 2.157 aggregate tok/s (+30.8%) while retaining the full +expert tier. Treat this as an opt-in until the projected dense set and the 2 GB +per-device runtime reserve fit the target GPUs. A measured `PIN` profile can promote its hottest experts into the persistent VRAM tier while keeping the rest in RAM: @@ -295,9 +304,10 @@ VRAM tier while keeping the rest in RAM: STATS=stats.txt SNAP=/nvme/glm52_i4 ./glm 64 4 4 # collect routing frequencies first COLI_CUDA=1 COLI_GPU=0 CUDA_EXPERT_GB=16 \ PIN=stats.txt PIN_GB=160 SNAP=/nvme/glm52_i4 ./glm 64 4 4 -# multi-GPU expert tier, 96 GB total budget across six devices -COLI_CUDA=1 COLI_GPUS=0,1,2,3,4,5 CUDA_EXPERT_GB=96 \ -PIN=stats.txt PIN_GB=160 SNAP=/nvme/glm52_i4 ./glm 64 4 4 +# multi-GPU expert tier, 150 GB total budget across six 32 GB devices +COLI_CUDA=1 COLI_GPUS=0,1,2,3,4,5 CUDA_EXPERT_GB=150 \ +CUDA_DENSE=1 PIN=stats.txt PIN_GB=300 RAM_GB=226 \ +SNAP=/nvme/glm52_i4 ./glm 64 4 4 ``` Selected experts are uploaded during startup, so capacity failures occur before @@ -305,14 +315,35 @@ inference and the log reports their exact tensor footprint. The budget is clampe against free VRAM after reserving the projected dense resident set and 2 GB of runtime headroom per selected device. With `COLI_GPUS`, `CUDA_EXPERT_GB` is a total budget across the device set; experts are assigned whole to the -least-loaded device that can hold them. A NUMA-local RAM backing store is not -implemented yet. +least-loaded device that can hold them. Multi-GPU runs also default to +`PIN_FILL=1`: the measured hot set is placed first, then unused VRAM is filled +with zero-heat experts. `CUDA_RELEASE_HOST=1` (the multi-GPU default) releases +the RAM copy after a successful upload and reloads it from disk only if CUDA +later fails. Set either variable to `0` to restore the conservative behavior. +When host backing is released, placement is disjoint and staged: the hottest +prefix is loaded, uploaded to VRAM, and freed before the next-ranked suffix is +loaded into RAM. `PIN_GB` therefore describes the combined ranked set rather +than duplicate RAM and VRAM copies. On a 256 GB dual-socket host, moving from a +150 GB VRAM + 130 GB RAM placement to 150 GB VRAM + 150 GB RAM raised fixed-token +replay from 1.87 to 2.16 tok/s (+15.7%), reduced expert disk wait from 5.144s to +3.948s, and kept the projected RAM peak below `RAM_GB=226`. The cache cap adjusts +down automatically (54 to 40 in that run) so the larger pinned tier does not exceed +the process budget. Start lower on hosts with less available RAM. +MTP speculation defaults off on CUDA because cold draft routes increase expert +traffic; an explicit `DRAFT=n` still overrides the default. + +On six RTX 5090 32 GB cards with GLM-5.2 int4, a 150 GB hot-first tier sustained +0.94 token/s over a 64-token varied prompt (87.8% expert hit rate), and reached +1.64 token/s on a warmed short prompt (99.3% hit rate). The same capacity filled +without routing heat managed only 0.29 token/s, so profile quality matters more +than raw VRAM capacity. These are single-run engineering measurements, not a +portable performance guarantee. Current limitations: devices use independent contexts and synchronous -host-staged activation copies—there is no P2P/NCCL dependency yet. The kernels -are correctness-first custom kernels rather than cuBLAS/Tensor Core kernels. -This draft intentionally makes no end-to-end speedup claim before the full model -is benchmarked. +host-staged activation copies—there is no P2P/NCCL dependency yet. Independent +expert groups execute concurrently across devices, but a single expert is not +sharded. The kernels are correctness-first custom kernels rather than +cuBLAS/Tensor Core kernels. For a reproducible backend A/B without the full checkpoint, generate the deterministic 313M-parameter `glm_moe_dsa` fixture and run fixed-token replay: @@ -344,6 +375,49 @@ compatible endpoint. Nothing leaves the endpoint you configure. The terminal Useful knobs (env or flags): `--temp T` token sampling temperature (default 0.7 + nucleus 0.90 — tuned for int4; 0 = greedy), `--topp 0.7` adaptive expert top-p (30–40% less disk), `--ngen N` max tokens per answer (`:more` in chat continues a truncated one), `--repin N` adapt RAM/VRAM hot experts every N emitted tokens, `AUTOPIN=0` disable the learning cache's auto-pin, `THINK=1` enable GLM-5.2's reasoning block, `DRAFT=n` MTP draft depth, `GRAMMAR=g.gbnf` grammar-forced drafts for constrained JSON/NDJSON output (`GRAMMAR_DRAFT=n` caps the forced span), `TF=1` teacher-forcing validation, `PILOT=1` router-lookahead disk prefetch (experimental — see below), `CAP_RAISE=0` don't auto-grow the expert cache. +### Resource policy + +`coli plan` reports the planned hot (VRAM), warm (RAM), and cold backing +(disk) tiers, the reason for each placement, and the expected bottleneck. The +default `--policy quality` and `--policy balanced` modes preserve checkpoint +quantization and router decisions unless `--topk` or `--topp` is passed; those +explicit lossy overrides print a warning and proceed. + +Auto-tier plans size OpenMP from physical cores and bind workers across cores. +Memory-bound quantized kernels can regress sharply when SMT siblings compete +for limited memory channels; explicit `OMP_*` settings always take precedence. + +```bash +coli plan --model /models/glm52_i4 --policy quality +coli run --auto-tier --policy quality "Explain MoE offloading" +# Explicit research-only router reduction: +coli run --policy experimental-fast --topk 4 "Benchmark prompt" +``` + +Disk is an immutable recovery source, not a normal decode target. If the plan +leaves cold expert bytes on disk, speed depends on cache hit rate; output +quality does not. + +Cold expert reads use a deferred pipeline: resident RAM/VRAM experts execute +while missing experts are loaded in a bounded background I/O pool, then the +cold results join before the layer completes. `IO_THREADS=n` overrides the +default eight loader threads when foreground work exists. Profiling reports +both disk service time and the smaller foreground-visible wait time so overlap +is explicit rather than credited as unexplained speedup. + +`--policy balanced` enables lossless live placement (`REPIN=64`). At safe +request boundaries, a per-layer LFRU score combines decaying session frequency +with recent access and replaces at most four sufficiently colder pinned +experts. `--policy quality` leaves live replacement off by default; `REPIN=0` +always disables it. Persistent `.coli_usage` history and session-local LFRU +state remain separate. + +For single-token q4 CPU experts, gate and up projections share one OpenMP +dispatch while retaining the same per-row AVX2/NEON arithmetic. This removes +one thread-team launch per RAM expert without activation requantization or a +lower-precision fallback. It is a stepping stone toward a persistent native +CPU expert pool, not a replacement for one. + **The expert cache auto-sizes to your RAM** (since 2026-07-10): the engine now *raises* the LRU cap to fill your `--ram` budget instead of only lowering it. Before this fix a 128 GB machine ran with the same 8-experts/layer cache as a 16 GB one (issue #12) — **if you benchmarked colibrì before this date, rerun: your numbers were capped.** **Router-lookahead prefetch** (`PILOT=1`, experimental): GLM-5.2's expert routing is measurably predictable *ahead of time* — applying layer L+1's router to layer L's post-attention state recalls **71.6%** of the true top-8 (vs 41.3% for "same experts as last token"). `PILOT=1` uses this to issue next-layer expert readahead from a dedicated I/O thread while the current layer computes. On our dev box the disk is already ~80% saturated, so it measures neutral; on machines where compute and disk are balanced (like the Ryzen AI 9 in issue #12: 43% disk / 46% matmul) it should overlap real work — measurements welcome. diff --git a/c/Makefile b/c/Makefile index 3833e22..d2da448 100644 --- a/c/Makefile +++ b/c/Makefile @@ -66,7 +66,7 @@ CUDA_ARCH ?= native NVCCFLAGS ?= -O3 -std=c++17 -arch=$(CUDA_ARCH) -Xcompiler=-Wall,-Wextra PYTHON ?= python3 CUDA_OBJ = -TEST_BINS = tests/test_json$(EXE) tests/test_st$(EXE) tests/test_tier$(EXE) tests/test_grammar$(EXE) tests/test_idot$(EXE) +TEST_BINS = tests/test_json$(EXE) tests/test_st$(EXE) tests/test_tier$(EXE) tests/test_grammar$(EXE) tests/test_decode_batch$(EXE) tests/test_idot$(EXE) ifeq ($(CUDA),1) ifeq ($(UNAME_S),Darwin) $(error CUDA=1 is supported only on Linux) @@ -118,6 +118,11 @@ cuda-test: backend_cuda.cu backend_cuda.h tests/test_backend_cuda.cu $(NVCC) $(NVCCFLAGS) backend_cuda.cu tests/test_backend_cuda.cu -o backend_cuda_test$(EXE) ./backend_cuda_test$(EXE) +cuda-bench: backend_cuda.cu backend_cuda.h tests/bench_tensor_core.cu + @command -v $(NVCC) >/dev/null 2>&1 || { echo "nvcc not found: set CUDA_HOME or NVCC" >&2; exit 1; } + $(NVCC) $(NVCCFLAGS) backend_cuda.cu tests/bench_tensor_core.cu -o backend_cuda_bench$(EXE) + ./backend_cuda_bench$(EXE) + olmoe$(EXE): olmoe.c st.h json.h compat.h $(CC) $(CFLAGS) olmoe.c -o olmoe$(EXE) $(LDFLAGS) @@ -140,6 +145,9 @@ tests/test_tier$(EXE): tests/test_tier.c tier.h tests/test_grammar$(EXE): tests/test_grammar.c grammar.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) +tests/test_decode_batch$(EXE): tests/test_decode_batch.c decode_batch.h + $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) + tests/test_idot$(EXE): tests/test_idot.c glm.c st.h json.h tok.h tok_unicode.h compat.h grammar.h tier.h $(CC) $(CFLAGS) $< -o $@ $(LDFLAGS) @@ -158,7 +166,7 @@ check: $(MAKE) test clean: - rm -f olmoe$(EXE) glm$(EXE) iobench$(EXE) backend_cuda.o backend_cuda_test$(EXE) backend_metal.o backend_metal_test $(TEST_BINS) + rm -f olmoe$(EXE) glm$(EXE) iobench$(EXE) backend_cuda.o backend_cuda_test$(EXE) backend_cuda_bench$(EXE) backend_metal.o backend_metal_test $(TEST_BINS) rm -rf tests/__pycache__ -.PHONY: all glm cuda-test portable test-c test-python test check clean +.PHONY: all glm cuda-test cuda-bench portable test-c test-python test check clean diff --git a/c/backend_cuda.cu b/c/backend_cuda.cu index b40d361..fda663a 100644 --- a/c/backend_cuda.cu +++ b/c/backend_cuda.cu @@ -1,9 +1,12 @@ #include "backend_cuda.h" #include +#include #include #include +#include +#include struct ColiCudaTensor { void *weights; @@ -15,13 +18,27 @@ struct ColiCudaTensor { typedef struct { int device; - float *x, *y; - size_t x_cap, y_cap; + float *x, *y, *gate, *up; + size_t x_cap, y_cap, gate_cap, up_cap; + uint8_t *qx; float *qscale; + size_t qx_cap, qscale_cap; + float *host_x,*host_y; size_t host_x_cap,host_y_cap; + float *aq,*al,*ar,*ac; size_t aq_cap,al_cap,ar_cap,ac_cap; + cudaStream_t stream; + void *group_desc; size_t group_desc_cap; size_t tensor_count, tensor_bytes; } DeviceContext; +typedef struct { + const void *g,*u,*d; const float *gs,*us,*ds; + int gf,uf,df,rows,offset; +} GroupDesc; + static DeviceContext g_ctx[COLI_CUDA_MAX_DEVICES]; static int g_nctx; +static uint64_t g_group_calls,g_group_experts,g_group_rows; +static double g_group_h2d_ms,g_group_kernel_ms,g_group_d2h_ms; +static std::mutex g_group_stats_mu; static int cuda_ok(cudaError_t err, const char *what) { if (err == cudaSuccess) return 1; @@ -38,7 +55,7 @@ static int select_ctx(DeviceContext *ctx) { return ctx && cuda_ok(cudaSetDevice(ctx->device), "select device"); } -static size_t row_bytes(int fmt, int I) { +__host__ __device__ static size_t row_bytes(int fmt, int I) { if (fmt == 0) return (size_t)I * sizeof(float); if (fmt == 1) return (size_t)I; if (fmt == 2) return (size_t)(I + 1) / 2; @@ -53,12 +70,16 @@ __device__ static float weight_at(const void *weights, int fmt, size_t row, int const uint8_t *q = base; if (fmt == 2) { uint8_t v = q[i >> 1]; - return static_cast(((i & 1) ? (v >> 4) : (v & 15)) - 8); + int n=(i&1)?(v>>4):(v&15); return static_cast(n&8?n-16:n); } uint8_t v = q[i >> 2]; return static_cast(((v >> ((i & 3) * 2)) & 3) - 2); } +__global__ static void offset_to_signed_s4(uint8_t *q,size_t n){ + size_t i=(size_t)blockIdx.x*blockDim.x+threadIdx.x;if(i=S)return; const float *xs=x+(size_t)s*K; + float v=0; for(int i=threadIdx.x;i>=1){if(threadIdx.x0?m[0]/7.f:1.f; if(!threadIdx.x)scale[s]=sc; + uint8_t *dst=q+(size_t)s*((K+1)/2); + for(int b=threadIdx.x;b<(K+1)/2;b+=blockDim.x){ + int i=b*2,a=__float2int_rn(xs[i]/sc),c=i+1= 750 + using namespace nvcuda; + int warp=threadIdx.x/32,lane=threadIdx.x%32,tile=blockIdx.x*8+warp,c=blockIdx.y; + if(tile*8>=O)return; GroupDesc d=desc[c]; + const void *w=which==0?d.g:(which==1?d.u:d.d); + const float *ws=which==0?d.gs:(which==1?d.us:d.ds); + int fmt=which==0?d.gf:(which==1?d.uf:d.df); + if(fmt!=2)return; + wmma::fragment acc; wmma::fill_fragment(acc,0); + const uint8_t *a=x+(size_t)d.offset*((K+1)/2); + const uint8_t *b=(const uint8_t*)w+(size_t)(tile*8)*((K+1)/2); + for(int k=0;k af; + wmma::fragment bf; + wmma::load_matrix_sync(af,a+k/2,K); + wmma::load_matrix_sync(bf,b+k/2,K); + wmma::mma_sync(acc,af,bf,acc); + } + __shared__ int out[8][64]; wmma::store_matrix_sync(out[warp],acc,8,wmma::mem_row_major); + for(int i=lane;i<64;i+=32){int s=i/8,o=tile*8+i%8; + if(s=d.rows) return; + const void *w=which?d.u:d.g; const float *sc=which?d.us:d.gs; int fmt=which?d.uf:d.gf; + size_t rb=row_bytes(fmt,D),row=(size_t)o*rb; const float *xs=x+(size_t)(d.offset+s)*D; + float sum=0; for(int i=threadIdx.x;i>=1){ if(threadIdx.x=d.rows) return; + size_t rb=row_bytes(d.df,I),row=(size_t)o*rb; const float *xs=x+(size_t)(d.offset+s)*I; + float sum=0; for(int i=threadIdx.x;i>=1){ if(threadIdx.x>4; *lo=(float)(a&8?a-16:a); *hi=(float)(b&8?b-16:b); +} + +/* Exact low-row W4A32 path. It consumes each packed weight byte once instead + * of routing both nibbles through weight_at(), preserving FP32 activations. */ +__global__ static void grouped_hidden_w4(float *y,const float *x,const GroupDesc *desc, + int I,int D,int which){ + int o=blockIdx.x,s=blockIdx.y,c=blockIdx.z;GroupDesc d=desc[c];if(s>=d.rows)return; + const uint8_t *w=(const uint8_t*)(which?d.u:d.g);const float *sc=which?d.us:d.gs; + const uint8_t *row=w+(size_t)o*((D+1)/2);const float *xs=x+(size_t)(d.offset+s)*D; + float sum=0;for(int b=threadIdx.x;b<(D+1)/2;b+=blockDim.x){float a,z;unpack_s4(row[b],&a,&z); + int i=b*2;sum+=xs[i]*a;if(i+1>=1){if(threadIdx.x=d.rows)return; + const uint8_t *gr=(const uint8_t*)d.g+(size_t)o*((D+1)/2); + const uint8_t *ur=(const uint8_t*)d.u+(size_t)o*((D+1)/2); + const float *xs=x+(size_t)(d.offset+s)*D;float ga=0,ua=0; + for(int b=threadIdx.x;b<(D+1)/2;b+=blockDim.x){float g0,g1,u0,u1;unpack_s4(gr[b],&g0,&g1);unpack_s4(ur[b],&u0,&u1); + int i=b*2;ga+=xs[i]*g0;ua+=xs[i]*u0;if(i+1>=1){if(threadIdx.x=d.rows)return; + const uint8_t *row=(const uint8_t*)d.d+(size_t)o*((I+1)/2); + const float *xs=x+(size_t)(d.offset+s)*I;float sum=0; + for(int b=threadIdx.x;b<(I+1)/2;b+=blockDim.x){float a,z;unpack_s4(row[b],&a,&z); + int i=b*2;sum+=xs[i]*a;if(i+1>=1){if(threadIdx.x= bytes) return 1; if (*ptr) cudaFree(*ptr); @@ -91,6 +243,16 @@ static int reserve(float **ptr, size_t *cap, size_t bytes) { return 1; } +static int reserve_bytes(void **ptr,size_t *cap,size_t bytes){ + if(*cap>=bytes) return 1; if(*ptr) cudaFree(*ptr); *ptr=nullptr; *cap=0; + if(!cuda_ok(cudaMalloc(ptr,bytes),"descriptor allocation")) return 0; *cap=bytes; return 1; +} + +static int reserve_pinned(float **ptr,size_t *cap,size_t bytes){ + if(*cap>=bytes)return 1;if(*ptr)cudaFreeHost(*ptr);*ptr=nullptr;*cap=0; + if(!cuda_ok(cudaMallocHost(ptr,bytes),"pinned staging allocation"))return 0;*cap=bytes;return 1; +} + extern "C" int coli_cuda_init(const int *devices, int count) { int available = 0; if (!devices || count < 1 || count > COLI_CUDA_MAX_DEVICES) return 0; @@ -114,6 +276,9 @@ extern "C" int coli_cuda_init(const int *devices, int count) { if (!select_ctx(ctx)) { g_nctx = 0; return 0; } cudaDeviceProp prop{}; if (!cuda_ok(cudaGetDeviceProperties(&prop, device), "device properties")) { g_nctx = 0; return 0; } + if(!cuda_ok(cudaStreamCreateWithFlags(&ctx->stream,cudaStreamNonBlocking),"stream creation")){ + g_nctx=0;return 0; + } g_nctx++; std::fprintf(stderr, "[CUDA] device %d: %s, %.1f GB VRAM, sm_%d%d\n", device, prop.name, prop.totalGlobalMem / 1e9, prop.major, prop.minor); @@ -127,8 +292,24 @@ extern "C" void coli_cuda_shutdown(void) { if (!select_ctx(ctx)) continue; if (ctx->x) cudaFree(ctx->x); if (ctx->y) cudaFree(ctx->y); - ctx->x = ctx->y = nullptr; - ctx->x_cap = ctx->y_cap = 0; + if (ctx->gate) cudaFree(ctx->gate); + if (ctx->up) cudaFree(ctx->up); + if (ctx->qx) cudaFree(ctx->qx); + if (ctx->qscale) cudaFree(ctx->qscale); + if(ctx->aq)cudaFree(ctx->aq);if(ctx->al)cudaFree(ctx->al);if(ctx->ar)cudaFree(ctx->ar);if(ctx->ac)cudaFree(ctx->ac); + if (ctx->host_x) cudaFreeHost(ctx->host_x); + if (ctx->host_y) cudaFreeHost(ctx->host_y); + if (ctx->stream) cudaStreamDestroy(ctx->stream); + if (ctx->group_desc) cudaFree(ctx->group_desc); + ctx->x = ctx->y = ctx->gate = ctx->up = nullptr; + ctx->qx=nullptr; ctx->qscale=nullptr; + ctx->aq=ctx->al=ctx->ar=ctx->ac=nullptr; + ctx->host_x=ctx->host_y=nullptr;ctx->stream=nullptr; + ctx->x_cap = ctx->y_cap = ctx->gate_cap = ctx->up_cap = 0; + ctx->qx_cap=ctx->qscale_cap=0; + ctx->aq_cap=ctx->al_cap=ctx->ar_cap=ctx->ac_cap=0; + ctx->host_x_cap=ctx->host_y_cap=0; + ctx->group_desc=nullptr; ctx->group_desc_cap=0; } g_nctx = 0; } @@ -155,6 +336,13 @@ extern "C" void coli_cuda_stats(int device, size_t *tensor_count, size_t *tensor if (tensor_bytes) *tensor_bytes = bytes; } +extern "C" void coli_cuda_group_stats(uint64_t *calls, uint64_t *experts, uint64_t *rows, + double *h2d_ms, double *kernel_ms, double *d2h_ms) { + if(calls) *calls=g_group_calls; if(experts) *experts=g_group_experts; if(rows) *rows=g_group_rows; + if(h2d_ms) *h2d_ms=g_group_h2d_ms; if(kernel_ms) *kernel_ms=g_group_kernel_ms; + if(d2h_ms) *d2h_ms=g_group_d2h_ms; +} + extern "C" int coli_cuda_tensor_upload(ColiCudaTensor **tensor, const void *weights, const float *scales, int fmt, int I, int O, int device) { @@ -174,6 +362,8 @@ extern "C" int coli_cuda_tensor_upload(ColiCudaTensor **tensor, coli_cuda_tensor_free(t); return 0; } + if(fmt==2){offset_to_signed_s4<<<(unsigned)((t->weight_bytes+255)/256),256>>>((uint8_t*)t->weights,t->weight_bytes); + if(!cuda_ok(cudaGetLastError(),"int4 weight conversion")){coli_cuda_tensor_free(t);return 0;}} if (fmt) { if (!cuda_ok(cudaMalloc(&t->scales, (size_t)O * sizeof(float)), "scale allocation") || !cuda_ok(cudaMemcpy(t->scales, scales, (size_t)O * sizeof(float), cudaMemcpyHostToDevice), "scale upload")) { @@ -207,6 +397,151 @@ extern "C" int coli_cuda_matmul(ColiCudaTensor **tensor, return 1; } +extern "C" int coli_cuda_expert_mlp(ColiCudaTensor *gate, ColiCudaTensor *up, + ColiCudaTensor *down, float *y, + const float *x, int S) { + if (!gate || !up || !down || !x || !y || S < 1 || + gate->device != up->device || gate->device != down->device || + gate->I != up->I || gate->O != up->O || + down->I != gate->O || down->O != gate->I) return 0; + DeviceContext *ctx = find_ctx(gate->device); + if (!select_ctx(ctx)) return 0; + int D = gate->I, I = gate->O; + size_t xb=(size_t)S*D*sizeof(float), ib=(size_t)S*I*sizeof(float); + size_t yb=(size_t)S*D*sizeof(float); + if (!reserve(&ctx->x,&ctx->x_cap,xb) || !reserve(&ctx->y,&ctx->y_cap,yb) || + !reserve(&ctx->gate,&ctx->gate_cap,ib) || !reserve(&ctx->up,&ctx->up_cap,ib)) return 0; + if (!cuda_ok(cudaMemcpy(ctx->x,x,xb,cudaMemcpyHostToDevice),"expert input upload")) return 0; + dim3 hidden_grid((unsigned)I,(unsigned)S), output_grid((unsigned)D,(unsigned)S); + quant_matmul<<>>(ctx->gate,ctx->x,gate->weights,gate->scales, + gate->fmt,S,D,I,row_bytes(gate->fmt,D)); + quant_matmul<<>>(ctx->up,ctx->x,up->weights,up->scales, + up->fmt,S,D,I,row_bytes(up->fmt,D)); + size_t n=(size_t)S*I; + silu_mul<<<(unsigned)((n+255)/256),256>>>(ctx->gate,ctx->up,n); + quant_matmul<<>>(ctx->y,ctx->gate,down->weights,down->scales, + down->fmt,S,I,D,row_bytes(down->fmt,I)); + if (!cuda_ok(cudaGetLastError(),"expert MLP launch") || + !cuda_ok(cudaMemcpy(y,ctx->y,yb,cudaMemcpyDeviceToHost),"expert output download")) return 0; + return 1; +} + +extern "C" int coli_cuda_expert_group(ColiCudaTensor *const *gates, + ColiCudaTensor *const *ups, + ColiCudaTensor *const *downs, + const int *rows, int count, + float *y, const float *x) { + if (!gates || !ups || !downs || !rows || !x || !y || count < 1) return 0; + ColiCudaTensor *first=gates[0]; + if (!first) return 0; + int device=first->device,D=first->I,I=first->O,total=0,max_rows=0; + GroupDesc host[64]; if(count>64) return 0; + int all_s4=1; + for(int c=0;cdevice!=device||u->device!=device||d->device!=device|| + g->I!=D||u->I!=D||g->O!=I||u->O!=I||d->I!=I||d->O!=D) return 0; + host[c]={g->weights,u->weights,d->weights,g->scales,u->scales,d->scales, + g->fmt,u->fmt,d->fmt,rows[c],total}; + all_s4&=g->fmt==2&&u->fmt==2&&d->fmt==2; + total+=rows[c]; if(rows[c]>max_rows) max_rows=rows[c]; + } + DeviceContext *ctx=find_ctx(device); if(!select_ctx(ctx)) return 0; + size_t xb=(size_t)total*D*sizeof(float), ib=(size_t)total*I*sizeof(float); + if(!reserve(&ctx->x,&ctx->x_cap,xb)||!reserve(&ctx->y,&ctx->y_cap,xb)|| + !reserve(&ctx->gate,&ctx->gate_cap,ib)||!reserve(&ctx->up,&ctx->up_cap,ib)|| + !reserve_bytes(&ctx->group_desc,&ctx->group_desc_cap,(size_t)count*sizeof(GroupDesc))) return 0; + int async=!getenv("COLI_CUDA_ASYNC")||atoi(getenv("COLI_CUDA_ASYNC")); + if(async&&(!reserve_pinned(&ctx->host_x,&ctx->host_x_cap,xb)|| + !reserve_pinned(&ctx->host_y,&ctx->host_y_cap,xb)))return 0; + cudaError_t copy_desc=async?cudaMemcpyAsync(ctx->group_desc,host,(size_t)count*sizeof(GroupDesc), + cudaMemcpyHostToDevice,ctx->stream) + :cudaMemcpy(ctx->group_desc,host,(size_t)count*sizeof(GroupDesc),cudaMemcpyHostToDevice); + if(!cuda_ok(copy_desc,"expert group descriptors"))return 0; + int profile=getenv("COLI_CUDA_PROFILE")&&atoi(getenv("COLI_CUDA_PROFILE")); + cudaEvent_t ev[4]={}; + if(profile) for(int i=0;i<4;i++) if(!cuda_ok(cudaEventCreate(&ev[i]),"profile event")) profile=0; + if(profile) cudaEventRecord(ev[0],ctx->stream); + if(async)std::memcpy(ctx->host_x,x,xb); + cudaError_t copy_x=async?cudaMemcpyAsync(ctx->x,ctx->host_x,xb,cudaMemcpyHostToDevice,ctx->stream) + :cudaMemcpy(ctx->x,x,xb,cudaMemcpyHostToDevice); + if(!cuda_ok(copy_x,"expert group input upload")) return 0; + if(profile) cudaEventRecord(ev[1],ctx->stream); + GroupDesc *dev=(GroupDesc*)ctx->group_desc; + int tc=getenv("COLI_CUDA_TC_INT4")&&atoi(getenv("COLI_CUDA_TC_INT4")); + tc=tc&&all_s4&&D%32==0&&I%32==0&&D%8==0&&I%8==0; + int tc_min=getenv("COLI_CUDA_TC_MIN_ROWS")?atoi(getenv("COLI_CUDA_TC_MIN_ROWS")):8; + for(int c=0;c=tc_min; + if(tc){ + size_t qb=(size_t)(total+7)*(size_t)(D>I?D:I)/2; + if(!reserve_bytes((void**)&ctx->qx,&ctx->qx_cap,qb)|| + !reserve(&ctx->qscale,&ctx->qscale_cap,(size_t)(total+7)*sizeof(float)))return 0; + cudaMemsetAsync(ctx->qx,0,qb,ctx->stream); + quantize_s4_rows<<stream>>>(ctx->qx,ctx->qscale,ctx->x,total,D); + grouped_s4_wmma<<stream>>>(ctx->gate,ctx->qx,ctx->qscale,dev,D,I,0); + grouped_s4_wmma<<stream>>>(ctx->up,ctx->qx,ctx->qscale,dev,D,I,1); + silu_mul<<<(unsigned)(((size_t)total*I+255)/256),256,0,ctx->stream>>>(ctx->gate,ctx->up,(size_t)total*I); + quantize_s4_rows<<stream>>>(ctx->qx,ctx->qscale,ctx->gate,total,I); + grouped_s4_wmma<<stream>>>(ctx->y,ctx->qx,ctx->qscale,dev,I,D,2); + }else if(all_s4&&(!getenv("COLI_CUDA_W4_PACKED")||atoi(getenv("COLI_CUDA_W4_PACKED")))){ + dim3 hg((unsigned)I,(unsigned)max_rows,(unsigned)count),og((unsigned)D,(unsigned)max_rows,(unsigned)count); + int dual=!getenv("COLI_CUDA_DUAL_PROJ")||atoi(getenv("COLI_CUDA_DUAL_PROJ")); + if(dual)grouped_hidden_w4_dual<<stream>>>(ctx->gate,ctx->up,ctx->x,dev,I,D); + else{ + grouped_hidden_w4<<stream>>>(ctx->gate,ctx->x,dev,I,D,0); + grouped_hidden_w4<<stream>>>(ctx->up,ctx->x,dev,I,D,1); + } + silu_mul<<<(unsigned)(((size_t)total*I+255)/256),256,0,ctx->stream>>>(ctx->gate,ctx->up,(size_t)total*I); + grouped_down_w4<<stream>>>(ctx->y,ctx->gate,dev,D,I); + }else{ + dim3 hg((unsigned)I,(unsigned)max_rows,(unsigned)count),og((unsigned)D,(unsigned)max_rows,(unsigned)count); + grouped_hidden<<stream>>>(ctx->gate,ctx->x,dev,I,D,0); + grouped_hidden<<stream>>>(ctx->up,ctx->x,dev,I,D,1); + silu_mul<<<(unsigned)(((size_t)total*I+255)/256),256,0,ctx->stream>>>(ctx->gate,ctx->up,(size_t)total*I); + grouped_down<<stream>>>(ctx->y,ctx->gate,dev,D,I); + } + if(profile) cudaEventRecord(ev[2],ctx->stream); + if(!async&&!cuda_ok(cudaStreamSynchronize(ctx->stream),"expert group synchronize"))return 0; + cudaError_t copy_y=async?cudaMemcpyAsync(ctx->host_y,ctx->y,xb,cudaMemcpyDeviceToHost,ctx->stream) + :cudaMemcpy(y,ctx->y,xb,cudaMemcpyDeviceToHost); + if(!cuda_ok(cudaGetLastError(),"expert group launch")||!cuda_ok(copy_y,"expert group output download"))return 0; + if(async){if(!cuda_ok(cudaStreamSynchronize(ctx->stream),"expert group synchronize"))return 0; + std::memcpy(y,ctx->host_y,xb);} + if(profile){ + cudaEventRecord(ev[3],ctx->stream); cudaEventSynchronize(ev[3]); float a=0,b=0,c=0; + cudaEventElapsedTime(&a,ev[0],ev[1]); cudaEventElapsedTime(&b,ev[1],ev[2]); + cudaEventElapsedTime(&c,ev[2],ev[3]); + { std::lock_guard lock(g_group_stats_mu); + g_group_h2d_ms+=a; g_group_kernel_ms+=b; g_group_d2h_ms+=c; } + for(int i=0;i<4;i++) cudaEventDestroy(ev[i]); + } + { std::lock_guard lock(g_group_stats_mu); + g_group_calls++; g_group_experts+=(uint64_t)count; g_group_rows+=(uint64_t)total; } + return 1; +} + +extern "C" int coli_cuda_attention_absorb(ColiCudaTensor *w,float *ctx,const float *q, + const float *latent,const float *rope,int H,int Q, + int R,int V,int K,int T,float scale){ + if(!w||!ctx||!q||!latent||!rope||H<1||Q<1||R<1||V<1||K<1||K>512||T<1||T>4096|| + w->I!=K||w->O!=H*(Q+V))return 0; + DeviceContext *dc=find_ctx(w->device);if(!select_ctx(dc))return 0; + size_t qb=(size_t)H*(Q+R)*sizeof(float),lb=(size_t)T*K*sizeof(float); + size_t rb=(size_t)T*R*sizeof(float),cb=(size_t)H*V*sizeof(float); + if(!reserve(&dc->aq,&dc->aq_cap,qb)||!reserve(&dc->al,&dc->al_cap,lb)|| + !reserve(&dc->ar,&dc->ar_cap,rb)||!reserve(&dc->ac,&dc->ac_cap,cb))return 0; + if(!cuda_ok(cudaMemcpyAsync(dc->aq,q,qb,cudaMemcpyHostToDevice,dc->stream),"attention q upload")|| + !cuda_ok(cudaMemcpyAsync(dc->al,latent,lb,cudaMemcpyHostToDevice,dc->stream),"attention latent upload")|| + !cuda_ok(cudaMemcpyAsync(dc->ar,rope,rb,cudaMemcpyHostToDevice,dc->stream),"attention rope upload"))return 0; + size_t shared=(size_t)(2*K+T)*sizeof(float); + attention_absorb_kernel<<stream>>>(dc->ac,dc->aq,dc->al,dc->ar,w->weights,w->scales, + w->fmt,H,Q,R,V,K,T,scale); + if(!cuda_ok(cudaGetLastError(),"attention absorb launch")|| + !cuda_ok(cudaMemcpyAsync(ctx,dc->ac,cb,cudaMemcpyDeviceToHost,dc->stream),"attention context download")|| + !cuda_ok(cudaStreamSynchronize(dc->stream),"attention synchronize"))return 0; + return 1; +} + extern "C" void coli_cuda_tensor_free(ColiCudaTensor *tensor) { if (!tensor) return; DeviceContext *ctx = find_ctx(tensor->device); diff --git a/c/backend_cuda.h b/c/backend_cuda.h index 5a7377b..2a1979c 100644 --- a/c/backend_cuda.h +++ b/c/backend_cuda.h @@ -21,6 +21,8 @@ int coli_cuda_device_at(int index); int coli_cuda_mem_info(int device, size_t *free_bytes, size_t *total_bytes); /* device < 0 returns aggregate statistics for all configured devices. */ void coli_cuda_stats(int device, size_t *tensor_count, size_t *tensor_bytes); +void coli_cuda_group_stats(uint64_t *calls, uint64_t *experts, uint64_t *rows, + double *h2d_ms, double *kernel_ms, double *d2h_ms); /* Upload without executing, so capacity failures happen during model startup. */ int coli_cuda_tensor_upload(ColiCudaTensor **tensor, @@ -38,6 +40,25 @@ int coli_cuda_matmul(ColiCudaTensor **tensor, const void *weights, const float *scales, int fmt, int S, int I, int O, int device); +/* Fused expert pipeline: y = down(silu(gate(x)) * up(x)). All three tensors + * must already be resident on one device. Activations cross PCIe once in + * each direction instead of once per matrix. */ +int coli_cuda_expert_mlp(ColiCudaTensor *gate, ColiCudaTensor *up, + ColiCudaTensor *down, float *y, const float *x, int S); + +/* Packed group of same-shaped experts. Inputs and outputs contain sum(rows) + * consecutive [D] rows in call order. */ +int coli_cuda_expert_group(ColiCudaTensor *const *gates, + ColiCudaTensor *const *ups, + ColiCudaTensor *const *downs, + const int *rows, int count, + float *y, const float *x); + +/* Decode-only MLA weight-absorption core for one token. kv_b is [H*(Q+V),K]. */ +int coli_cuda_attention_absorb(ColiCudaTensor *kv_b,float *ctx,const float *q, + const float *latent,const float *rope,int H,int Q, + int R,int V,int K,int T,float attention_scale); + void coli_cuda_tensor_free(ColiCudaTensor *tensor); size_t coli_cuda_tensor_bytes(const ColiCudaTensor *tensor); int coli_cuda_tensor_device(const ColiCudaTensor *tensor); diff --git a/c/coli b/c/coli index e4e89ea..05a14a7 100755 --- a/c/coli +++ b/c/coli @@ -127,6 +127,7 @@ def resource_request(a, env): def env_for(a): e = dict(os.environ, SNAP=a.model) + e["COLI_POLICY"]=a.policy if a.ram: e["RAM_GB"]=str(a.ram) if a.ngen: e["NGEN"]=str(a.ngen) if a.topp: e["TOPP"]=str(a.topp) @@ -147,7 +148,7 @@ def env_for(a): if a.vram and a.gpu!="none": e["CUDA_EXPERT_GB"]=str(a.vram) try: ram,ctx,devices,vram=resource_request(a,e) - plan=build_plan(a.model,ram,ctx,devices,vram) + plan=build_plan(a.model,ram,ctx,devices,vram,policy=a.policy) except (OSError,ValueError,json.JSONDecodeError) as error: sys.exit(f"{C.yel}invalid resource plan:{C.r} {error}") has_cuda=cuda_binary() @@ -325,7 +326,7 @@ def cmd_plan(a): ram,ctx,devices,vram=resource_request(a,os.environ) if ctx<1: raise ValueError("--ctx must be positive") if a.vram<0: raise ValueError("--vram cannot be negative") - plan=build_plan(a.model,ram,ctx,devices,vram) + plan=build_plan(a.model,ram,ctx,devices,vram,policy=a.policy) except (OSError, ValueError, json.JSONDecodeError) as error: sys.exit(f"{C.yel}cannot create resource plan:{C.r} {error}") if a.json: @@ -516,6 +517,9 @@ def main(): common.add_argument("--ctx",type=int,default=0) common.add_argument("--gpu",default=None,help="auto, none, or a device list such as 0,1") common.add_argument("--vram",type=float,default=0,help="total VRAM budget in GB (0=auto)") + common.add_argument("--policy",choices=("quality","balanced","experimental-fast"), + default=os.environ.get("COLI_POLICY","quality"), + help="resource policy (explicit --topk/--topp overrides warn and proceed)") common.add_argument("--repin", type=int, default=0, help="adapt RAM/VRAM experts every N tokens") common.add_argument("--cap", type=int, default=8); common.add_argument("--ngen", type=int, default=1024) # rete di sicurezza: la fine vera la decidono gli stop token common.add_argument("--topp", type=float, default=0); common.add_argument("--topk", type=int, default=0) diff --git a/c/decode_batch.h b/c/decode_batch.h new file mode 100644 index 0000000..ad3d2ed --- /dev/null +++ b/c/decode_batch.h @@ -0,0 +1,37 @@ +#ifndef COLIBRI_DECODE_BATCH_H +#define COLIBRI_DECODE_BATCH_H + +#include +#include +#include + +/* `base` belongs to one sequence's KV state. Keeping this arithmetic in a + * model-independent seam makes ragged decode row ownership directly testable. */ +static inline float *coli_kv_row(float *base, int position, int width) +{ + return base + (size_t)position * (size_t)width; +} + +typedef struct { + unsigned long long id, bytes; + int slot, max_tokens; + float temperature, top_p; +} ColiSubmit; + +/* Parse the textual header. The payload is read separately using `bytes`, so + * it may contain newlines. Reject trailing fields to keep framing unambiguous. */ +static inline int coli_submit_parse(const char *line, ColiSubmit *s) +{ + char tail; + if (!line || !s || + sscanf(line, "SUBMIT %llu %d %llu %d %f %f %c", &s->id, &s->slot, + &s->bytes, &s->max_tokens, &s->temperature, &s->top_p, + &tail) != 6) + return 0; + return s->id > 0 && s->bytes <= (16u << 20) && s->slot >= 0 && s->max_tokens >= 1 && + isfinite(s->temperature) && isfinite(s->top_p) && + s->temperature >= 0 && s->temperature <= 2 && + s->top_p > 0 && s->top_p <= 1; +} + +#endif diff --git a/c/glm.c b/c/glm.c index 981e6d7..1f1e603 100644 --- a/c/glm.c +++ b/c/glm.c @@ -27,6 +27,7 @@ #include /* PIPE ready-flags/job queue + PILOT_REAL cross-layer handshake */ #include /* sched_yield: PIPE spin / PILOT barrier */ #include +#include #if defined(__APPLE__) || defined(__linux__) #include #include /* mlock: inchioda le pagine in RAM / wire pages into RAM */ @@ -36,6 +37,7 @@ #include "tok.h" #include "tier.h" #include "grammar.h" /* metodo F: draft grammaticali (#48) */ +#include "decode_batch.h" #ifdef _OPENMP #include /* scratch per-thread nell'attention */ #else @@ -130,6 +132,11 @@ typedef struct { char disk_path[2048]; } KVState; +typedef struct { + KVState *kv; + int token, pos; +} DecodeRow; + typedef struct { Cfg c; shards S; int ebits, dbits; /* bit expert / bit densa */ @@ -146,6 +153,7 @@ typedef struct { ESlot **pin; int *npin; /* HOT-STORE: expert pinnati in RAM (mai evicted) */ uint32_t **eusage; /* contatori persistenti (per STATS/PIN) */ uint32_t **eheat; /* calore recente per promotion/demotion live */ + uint32_t **elast, eaccess_clock; /* recency per LFRU session-local */ /* DSA lightning indexer (attivo solo se i pesi out-idx-* sono presenti) */ int has_dsa; QT *ix_wq, *ix_wk, *ix_wp; /* per layer FULL: wq_b, wk, weights_proj */ @@ -161,7 +169,8 @@ typedef struct { uint64_t eclock, hits, miss, ereq; uint64_t gpu_expert_calls; int gpu_expert_count; int64_t gpu_expert_bytes; uint64_t n_fw, n_emit; /* metodo E: forward di decode / token emessi */ - double t_edisk, t_emm, t_attn, t_kvb, t_head;/* profiling: dove va il tempo (sempre attivo) */ + double t_edisk, t_ewait, t_emm, t_attn, t_kvb, t_head;/* profiling: dove va il tempo */ + double t_aproj,t_acore,t_aout; /* attention breakdown */ int64_t resident_bytes; } Model; @@ -170,6 +179,7 @@ static void usage_save(Model *m); /* cache che impara: definita accanto a static int g_cuda_enabled; static double g_cuda_expert_gb; static int g_cuda_dense; +static int g_cuda_release_host; static int g_cuda_devices[COLI_CUDA_MAX_DEVICES], g_cuda_ndev, g_cuda_rr; static int64_t g_cuda_dense_projected[COLI_CUDA_MAX_DEVICES]; static void qt_cuda_reset(QT *t){ @@ -188,6 +198,14 @@ static void cuda_stats_print(void){ coli_cuda_stats(g_cuda_devices[i],&n,&b); fprintf(stderr,"[CUDA] device %d: %zu tensors, %.2f GB\n",g_cuda_devices[i],n,b/1e9); } + uint64_t calls=0,experts=0,rows=0; double h2d=0,kernel=0,d2h=0; + coli_cuda_group_stats(&calls,&experts,&rows,&h2d,&kernel,&d2h); + if(calls) fprintf(stderr,"[CUDA] expert groups: %llu call, %llu expert, %llu righe " + "(%.2f expert/call)%s\n",(unsigned long long)calls,(unsigned long long)experts, + (unsigned long long)rows,(double)experts/calls, + getenv("COLI_CUDA_PROFILE")?"; timing sotto":""); + if(calls&&getenv("COLI_CUDA_PROFILE")) fprintf(stderr, + "[CUDA] expert groups timing: H2D %.1f ms | kernel %.1f ms | D2H %.1f ms\n",h2d,kernel,d2h); } static int parse_cuda_devices(const char *list, int *out){ if(!list||!*list) return 0; @@ -280,6 +298,53 @@ static void matmul_i4(float *y, const float *x, const uint8_t *q4, const float * if(i>1]; int lo=(int)(byte&0xF)-8; a += xs[i]*(float)lo; } y[(int64_t)s*O+o]=a*sc; } } } +/* Decode hot path for gate+up: same exact q4 dot products as matmul_i4, but one + * OpenMP dispatch covers both matrices. KTransformers uses persistent pools; + * this keeps colibri dependency-free while removing one team launch/expert. */ +static void matmul_i4_pair(float *yg, float *yu, const float *x, + const uint8_t *qg, const float *sg, + const uint8_t *qu, const float *su, int I, int O){ + int rb=(I+1)/2; + #pragma omp parallel for schedule(static) + for(int z=0;z<2*O;z++){ + int o=z>1))); + __m128i lo=_mm_and_si128(by,m4),hi=_mm_and_si128(_mm_srli_epi16(by,4),m4); + __m128i nib=_mm_unpacklo_epi8(lo,hi); + __m256 w0=_mm256_cvtepi32_ps(_mm256_sub_epi32(_mm256_cvtepu8_epi32(nib),b8)); + __m256 w1=_mm256_cvtepi32_ps(_mm256_sub_epi32(_mm256_cvtepu8_epi32(_mm_srli_si128(nib,8)),b8)); + acc=_mm256_fmadd_ps(_mm256_loadu_ps(x+i),w0,acc); + acc=_mm256_fmadd_ps(_mm256_loadu_ps(x+i+8),w1,acc); } + a=hsum256(acc); +#elif defined(__ARM_NEON) + const uint8x8_t m4=vdup_n_u8(0x0F); const int8x8_t b8=vdup_n_s8(8); + float32x4_t ac0=vdupq_n_f32(0),ac1=vdupq_n_f32(0); + for(;i+16<=I;i+=16){ uint8x8_t by=vld1_u8(w+(i>>1)); + uint8x8x2_t n=vzip_u8(vand_u8(by,m4),vshr_n_u8(by,4)); + int16x8_t w0=vmovl_s8(vsub_s8(vreinterpret_s8_u8(n.val[0]),b8)); + int16x8_t w1=vmovl_s8(vsub_s8(vreinterpret_s8_u8(n.val[1]),b8)); + ac0=vfmaq_f32(ac0,vld1q_f32(x+i),vcvtq_f32_s32(vmovl_s16(vget_low_s16(w0)))); + ac1=vfmaq_f32(ac1,vld1q_f32(x+i+4),vcvtq_f32_s32(vmovl_s16(vget_high_s16(w0)))); + ac0=vfmaq_f32(ac0,vld1q_f32(x+i+8),vcvtq_f32_s32(vmovl_s16(vget_low_s16(w1)))); + ac1=vfmaq_f32(ac1,vld1q_f32(x+i+12),vcvtq_f32_s32(vmovl_s16(vget_high_s16(w1)))); } + a=vaddvq_f32(vaddq_f32(ac0,ac1)); +#endif + for(;i+1>1]; a+=x[i]*(float)((b&15)-8)+x[i+1]*(float)((b>>4)-8); } + if(i>1]&15)-8); + (zfmt==2&&wu->fmt==2&&wg->I==wu->I&&wg->O==wu->O) + matmul_i4_pair(g,u,x,wg->q4,wg->s,wu->q4,wu->s,wg->I,wg->O); + else { matmul_qt(g,x,wg,S); matmul_qt(u,x,wu,S); } +} /* y[S,O] = x[S,I] @ W^T con W int2 impacchettato (4 valori/byte) + scala[O]. nibble 2-bit -> [-2,1]. */ static void matmul_i2(float *y, const float *x, const uint8_t *q2, const float *scale, int S, int I, int O){ int rb=(I+3)/4; @@ -861,6 +926,7 @@ static void model_init(Model *m, const char *snap, int cap, int ebits, int dbits m->eroute=calloc(NR,sizeof(int*)); m->enr=calloc(NR,sizeof(int)); m->pin=calloc(NR,sizeof(ESlot*)); m->npin=calloc(NR,sizeof(int)); m->eusage=calloc(NR,sizeof(uint32_t*)); m->eheat=calloc(NR,sizeof(uint32_t*)); + m->elast=calloc(NR,sizeof(uint32_t*)); m->kv=calloc(1,sizeof(KVState)); m->kv_start=m->kv->kv_start=calloc(NR,sizeof(int)); for(int i=0;in_layers;i++){ @@ -891,6 +957,7 @@ static void model_init(Model *m, const char *snap, int cap, int ebits, int dbits m->eroute[i]=calloc(c->topk,sizeof(int)); /* metodo C: ultimo routing del layer */ m->eusage[i]=calloc(c->n_experts,sizeof(uint32_t)); m->eheat[i]=calloc(c->n_experts,sizeof(uint32_t)); + m->elast[i]=calloc(c->n_experts,sizeof(uint32_t)); } #undef P } @@ -936,6 +1003,7 @@ static void model_init(Model *m, const char *snap, int cap, int ebits, int dbits m->eroute[i]=calloc(c->topk,sizeof(int)); m->eusage[i]=calloc(c->n_experts,sizeof(uint32_t)); m->eheat[i]=calloc(c->n_experts,sizeof(uint32_t)); + m->elast[i]=calloc(c->n_experts,sizeof(uint32_t)); m->kv_start[i]=-1; /* KV MTP: parte dalla prima posizione di decode */ #undef PM } @@ -1279,6 +1347,24 @@ static inline void pipe_wait(int q){ while(!atomic_load_explicit(&g_pp.ready[q],memory_order_acquire)) sched_yield(); } +#ifdef COLI_CUDA +static void expert_host_release(Model *m, ESlot *s){ + if(!s->slab&&!s->fslab) return; +#if defined(__APPLE__) || defined(__linux__) + if(s->slab) munlock(s->slab,(size_t)s->slab_cap); + if(s->fslab) munlock(s->fslab,(size_t)s->fslab_cap*sizeof(float)); +#endif + int64_t bytes=qt_bytes(&s->g)+qt_bytes(&s->u)+qt_bytes(&s->d); + free(s->slab); free(s->fslab); s->slab=NULL; s->fslab=NULL; s->slab_cap=s->fslab_cap=0; + QT *q[3]={&s->g,&s->u,&s->d}; + for(int k=0;k<3;k++){ q[k]->qf=NULL; q[k]->q8=NULL; q[k]->q4=NULL; q[k]->s=NULL; } + m->resident_bytes-=bytes; if(m->resident_bytes<0) m->resident_bytes=0; +} +static void expert_host_ensure(Model *m, int layer, ESlot *s){ + if(!s->slab) expert_load(m,layer,s->eid,s,1); +} +#endif + /* prefetch asincrono dei pesi di un expert (e delle sue scale .qs): avvia il readahead * cosi' le letture sincrone successive trovano la page-cache calda. */ static void expert_prefetch(Model *m, int layer, int eid){ @@ -1324,7 +1410,10 @@ static int cmp_fdesc(const void *a,const void *b){ float x=*(const float*)a, y=*(const float*)b; return xy?-1:0; } /* attenzione MLA con KV-cache compressa, su token nuovi x[S,hidden], pos_base = pos del primo */ -static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_base, float *out){ +/* kvs/pos describe a ragged decode batch: each row may belong to a different + * sequence. NULL keeps the original contiguous, currently-bound KV path. */ +static void attention_rows(Model *m, Layer *l, int layer, float *x, int S, int pos_base, + KVState *const *kvs, const int *positions, float *out){ Cfg *c=&m->c; int H=c->n_heads, D=c->hidden, qh=c->qk_head, vh=c->v_head; int kvb_dim=H*(c->qk_nope+vh), Tk=pos_base+S; double ta0=now_s(); @@ -1361,14 +1450,16 @@ static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_ba /* 1) per ogni token nuovo: query roped + latente normato e k_rot roped -> in cache. * QR tiene il residuo q_a per TUTTE le posizioni: serve anche all'indexer DSA. */ for(int s=0;skv; + const float *xs=x+(int64_t)s*D; int pos=positions?positions[s]:pos_base+s; float *qresid=QR+(int64_t)s*c->q_lora; matmul_qt(qresid, xs, &l->q_a, 1); rmsnorm(qresid, qresid, l->q_a_ln, c->q_lora, c->eps); float *qfull=Q+(int64_t)s*H*qh; matmul_qt(qfull, qresid, &l->q_b, 1); for(int h=0;hqk_nope, pos, c); matmul_qt(comp, xs, &l->kv_a, 1); - float *Ldst=m->Lc[layer]+(int64_t)pos*c->kv_lora, *Rdst=m->Rc[layer]+(int64_t)pos*c->qk_rope; + float *Ldst=coli_kv_row(ks->Lc[layer],pos,c->kv_lora); + float *Rdst=coli_kv_row(ks->Rc[layer],pos,c->qk_rope); memcpy(Ldst, comp, c->kv_lora*sizeof(float)); rmsnorm(Ldst, Ldst, l->kv_a_ln, c->kv_lora, c->eps); /* latente normato */ memcpy(Rdst, comp+c->kv_lora, c->qk_rope*sizeof(float)); @@ -1379,12 +1470,13 @@ static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_ba * dai layer SHARED successivi). Selezione attiva solo con contesto > index_topk * (o DSA_FORCE=1 per il test: selezionare TUTTO deve dare l'output denso esatto). */ const int *dsel=NULL, *dnsel=NULL; int dtopk=0; - if(m->has_dsa && layern_layers && m->kv_start[layer]==0){ + if(m->has_dsa && layern_layers && ((!kvs && m->kv_start[layer]==0) || kvs)){ int nh=c->index_nh, hd=c->index_hd; dtopk=c->index_topk; if(c->idx_type[layer]){ for(int s=0;sIc[layer]+(int64_t)pos*hd; + KVState *ks=kvs?kvs[s]:m->kv; + const float *xs=x+(int64_t)s*D; int pos=positions?positions[s]:pos_base+s; + float *kd=coli_kv_row(ks->Ic[layer],pos,hd); matmul_qt(kd, xs, &m->ix_wk[layer], 1); layernorm(kd, m->ix_knw[layer], m->ix_knb[layer], hd, 1e-6f); rope_interleave(kd, pos, c); /* primi qk_rope dim, interleaved */ @@ -1397,18 +1489,20 @@ static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_ba } #pragma omp parallel for schedule(dynamic,1) for(int s=0;skv; + int pos=positions?positions[s]:pos_base+s, nk=pos+1; + if(ks->kv_start[layer]!=0){ m->dsa_nsel[s]=0; continue; } if(nk<=dtopk && !g_dsa_force){ m->dsa_nsel[s]=0; continue; } int keep = nkq_lora, &m->ix_wq[layer], 1); for(int h=0;hix_wp[layer], 1); float wsc=1.f/sqrtf((float)nh), rs=1.f/sqrtf((float)hd); float *isc=falloc(nk); for(int t=0;tIc[layer]+(int64_t)t*hd; + const float *kt=coli_kv_row(ks->Ic[layer],t,hd); float a=0; for(int h=0;hthr) dst[nd++]=t; for(int t=0;tdsa_nsel[s]=nd; - free(qi); free(isc); free(tmp); + free(qi); free(w32); free(isc); free(tmp); } } if(m->dsa_nsel){ dsel=m->dsa_sel; dnsel=m->dsa_nsel; } @@ -1433,31 +1527,48 @@ static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_ba * k/v per ogni token del contesto. Per linearita': * q·k_nope_t = (W_K^hT q_nope)·L_t ctx^h = W_V^h (Σ_t a_t L_t) * costo per step ~O(T·kv_lora) invece di O(T·H·(nope+vh)) del matmul kvb_all. */ - int absorb = g_absorb==1 || (g_absorb<0 && S<=4); + int absorb = kvs || g_absorb==1 || (g_absorb<0 && S<=4); if(absorb && c->kv_lora<=512){ + m->t_aproj+=now_s()-ta0; double tac=now_s(); int kvl=c->kv_lora, r0v=c->qk_nope; /* offset righe V dentro il blocco di testa */ - /* punteggi per-thread sul HEAP: un sc[8192] fisso sullo stack va in overflow quando - * il layer attende su tutto il contesto (nessuna selezione DSA: snapshot senza - * indexer, o layer MTP) e nt supera 8192 — scrittura oltre lo stack del worker - * OMP => segfault (e poco sotto il limite: corruzione SILENZIOSA dello stack). */ - int64_t sc_cap = Tk - m->kv_start[layer]; /* nt massimo (kv_start=-1 del MTP: +1, ok) */ + /* punteggi per-thread sul HEAP (vedi dev): cap Tk+1 copre anche il kv_start + * per-slot del percorso kvs (MTP: kv_start=-1 -> nt=Tk+1). */ + int64_t sc_cap = (int64_t)Tk+1; float *sc_all = falloc((int64_t)omp_get_max_threads()*sc_cap); + int cuda_core=0; +#ifdef COLI_CUDA + if(S<=4&&g_cuda_enabled&&getenv("COLI_CUDA_ATTN")&&atoi(getenv("COLI_CUDA_ATTN"))&& + l->kv_b.cuda_eligible&&qt_cuda_upload(&l->kv_b)){ + cuda_core=1; + for(int s=0;skv;int pos=positions?positions[s]:pos_base+s; + int st0=ks->kv_start[layer],nt=pos+1-st0; + if(dnsel&&dnsel[s]>0){cuda_core=0;break;} + cuda_core=coli_cuda_attention_absorb(l->kv_b.cuda,ctx+(int64_t)s*H*vh, + Q+(int64_t)s*H*qh,coli_kv_row(ks->Lc[layer],st0,kvl), + coli_kv_row(ks->Rc[layer],st0,c->qk_rope),H,c->qk_nope,c->qk_rope, + vh,kvl,nt,c->attn_scale); + } + } +#endif + if(!cuda_core){ #pragma omp parallel for collapse(2) schedule(static) for(int s=0;skv; + int pos=positions?positions[s]:pos_base+s; const float *qp=Q+(int64_t)s*H*qh+(int64_t)h*qh; const float *qr=qp+c->qk_nope; int rbase=h*(c->qk_nope+vh); float qabs[512]; memset(qabs,0,kvl*sizeof(float)); for(int d=0;dqk_nope;d++) qt_addrow(&l->kv_b, rbase+d, qp[d], qabs); float *sc = sc_all + (int64_t)omp_get_thread_num()*sc_cap; - int st0=m->kv_start[layer]; + int st0=ks->kv_start[layer]; int ns = (dnsel && dnsel[s]>0) ? dnsel[s] : 0; /* DSA: lista top-k o range pieno */ const int *tlist = ns ? dsel+(int64_t)s*dtopk : NULL; int nt = ns ? ns : pos+1-st0; for(int jj=0;jjLc[layer]+(int64_t)t*kvl; - const float *kr=m->Rc[layer]+(int64_t)t*c->qk_rope; + const float *Lt=coli_kv_row(ks->Lc[layer],t,kvl); + const float *kr=coli_kv_row(ks->Rc[layer],t,c->qk_rope); float a=0; for(int i=0;iqk_rope;d++) a+=qr[d]*kr[d]; sc[jj]=a*c->attn_scale; @@ -1465,17 +1576,19 @@ static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_ba softmax(sc,nt); float clat[512]; memset(clat,0,kvl*sizeof(float)); for(int jj=0;jjLc[layer]+(int64_t)t*kvl; + const float *Lt=coli_kv_row(ks->Lc[layer],t,kvl); float a=sc[jj]; for(int i=0;ikv_b, rbase+r0v, vh, clat, ctx+((int64_t)s*H+h)*vh); } - matmul_qt(out, ctx, &l->o, S); + } + m->t_acore+=now_s()-tac; double tao=now_s(); + matmul_qt(out, ctx, &l->o, S); m->t_aout+=now_s()-tao; free(ctx); free(Q); free(QR); free(comp); free(sc_all); m->t_attn += now_s()-ta0; return; } /* 2) ricostruzione di k_nope+value per TUTTI i token 0..Tk-1 (un solo matmul su kv_b) */ - double tk0=now_s(); + m->t_aproj+=now_s()-ta0; double tk0=now_s(); int stL=m->kv_start[layer]; float *kvb_all=falloc((int64_t)Tk*kvb_dim); matmul_qt(kvb_all+(int64_t)stL*kvb_dim, m->Lc[layer]+(int64_t)stL*c->kv_lora, &l->kv_b, Tk-stL); @@ -1484,6 +1597,7 @@ static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_ba * (punteggi sul heap, per-thread: vedi il commento nel ramo absorb) */ int64_t sc_cap = Tk - stL; float *sc_all = falloc((int64_t)omp_get_max_threads()*sc_cap); + double tac=now_s(); #pragma omp parallel for collapse(2) schedule(static) for(int s=0;sqk_nope+vh)+c->qk_nope; float a=sc[jj]; for(int d=0;do, S); + m->t_acore+=now_s()-tac; double tao=now_s(); + matmul_qt(out, ctx, &l->o, S); m->t_aout+=now_s()-tao; free(ctx); free(Q); free(QR); free(comp); free(kvb_all); free(sc_all); m->t_attn += now_s()-ta0; } +static void attention(Model *m, Layer *l, int layer, float *x, int S, int pos_base, float *out){ + attention_rows(m,l,layer,x,S,pos_base,NULL,NULL,out); +} + /* MoE GLM su x[S,hidden] -> out (router sigmoid/noaux_tc, n_group=1, + shared expert). * BATCH-UNION: per S>1 (prefill, verifica MTP) ogni expert UNICO del batch viene caricato * una volta sola e moltiplicato per tutte le posizioni che lo usano (pesi letti 1 volta); @@ -1574,6 +1693,7 @@ static void moe(Model *m, Layer *l, int layer, float *x, int S, float *out){ for(int kk=0;kkeusage[layer][idx[kk]]++; if(m->eheat[layer][idx[kk]]eheat[layer][idx[kk]]++; + m->elast[layer][idx[kk]]=++m->eaccess_clock; } if(c->norm_topk){ float sm=0; for(int kk=0;kkrouted_scale; @@ -1603,6 +1723,13 @@ static void moe(Model *m, Layer *l, int layer, float *x, int S, float *out){ /* ---- FASE C/D: risolvi (pin/cache/disco) e calcola, a blocchi di 64 unici ---- */ float *xg=falloc((int64_t)S*D), *gg=falloc((int64_t)S*I), *uu=falloc((int64_t)S*I), *hh=falloc((int64_t)S*D); int *rows=malloc(S*sizeof(int)); float *rw=malloc(S*sizeof(float)); +#ifdef COLI_CUDA + int group_enabled=S<=64; + float *group_x=group_enabled?falloc((int64_t)S*K*D):NULL; + float *group_y=group_enabled?falloc((int64_t)S*K*D):NULL; + int *group_row=group_enabled?malloc((size_t)64*S*sizeof(int)):NULL; + float *group_weight=group_enabled?malloc((size_t)64*S*sizeof(float)):NULL; +#endif int shared_on_gpu=0; (void)shared_on_gpu; /* set by the Metal path when Phase E was fused */ for(int base=0;baseg.cuda_eligible) m->gpu_expert_calls++; + if(group_enabled && g_cuda_enabled && e->g.cuda_eligible && e->u.cuda_eligible && e->d.cuda_eligible && + !omp_in_parallel()){ + group_e[ngroup]=e; group_n[ngroup]=nr; + for(int r=0;rg, nr); - matmul_qt(uu, xg, &e->u, nr); +#ifdef COLI_CUDA + if(!group_enabled && g_cuda_enabled && e->g.cuda_eligible && e->u.cuda_eligible && + e->d.cuda_eligible && !omp_in_parallel() && + coli_cuda_expert_mlp(e->g.cuda,e->u.cuda,e->d.cuda,hh,xg,nr)){ + for(int r=0;rt_emm+=now_s()-t0; continue; + } + if(!e->slab) expert_host_ensure(m,layer,e); +#endif + expert_gate_up(gg,uu,xg,&e->g,&e->u,nr); for(int64_t z=0;z<(int64_t)nr*I;z++) gg[z]=siluf(gg[z])*uu[z]; matmul_qt(hh, gg, &e->d, nr); for(int r=0;rt_emm += now_s()-t0; } +#ifdef COLI_CUDA + ColiCudaTensor *dev_g[COLI_CUDA_MAX_DEVICES][64],*dev_u[COLI_CUDA_MAX_DEVICES][64]; + ColiCudaTensor *dev_d[COLI_CUDA_MAX_DEVICES][64]; + int dev_rows[COLI_CUDA_MAX_DEVICES][64],dev_which[COLI_CUDA_MAX_DEVICES][64]; + int dev_nc[COLI_CUDA_MAX_DEVICES]={0},dev_total[COLI_CUDA_MAX_DEVICES]={0}; + int dev_off[COLI_CUDA_MAX_DEVICES]={0},dev_ok[COLI_CUDA_MAX_DEVICES]={0}; + for(int di=0;dig.cuda_device==g_cuda_devices[di]) dev_total[di]+=group_n[q]; + for(int di=1;dig.cuda_device==device){ + int nc=dev_nc[di]++; ESlot *e=group_e[q]; + dev_g[di][nc]=e->g.cuda; dev_u[di][nc]=e->u.cuda; dev_d[di][nc]=e->d.cuda; + dev_rows[di][nc]=group_n[q]; dev_which[di][nc]=q; + for(int r=0;r1) schedule(static) + for(int di=0;dig.cuda,e->u.cuda,e->d.cuda,hh,xg,nr)){ + expert_host_ensure(m,layer,e); + expert_gate_up(gg,uu,xg,&e->g,&e->u,nr); + for(int64_t z=0;z<(int64_t)nr*I;z++) gg[z]=siluf(gg[z])*uu[z]; + matmul_qt(hh,gg,&e->d,nr); + } + } + float *src=dev_ok[di]?group_y+(int64_t)off*D:hh; + for(int r=0;rt_emm+=now_s()-tg; +#endif /* No drain barrier: the per-expert pipe_wait(qof[j]) above (issued for every * dispatched miss slot, before the nr==0 skip) already waited on all ws[] loads * for this block, so they are complete before the LRU swap — and the gen-tagged @@ -1783,6 +1974,9 @@ static void moe(Model *m, Layer *l, int layer, float *x, int S, float *out){ } free(logit); free(choice); free(idxs); free(ws); free(keff); free(uniq); free(xg); free(gg); free(uu); free(hh); free(rows); free(rw); free(sg); free(su); +#ifdef COLI_CUDA + free(group_x); free(group_y); free(group_row); free(group_weight); +#endif } static void dense_mlp(Layer *l, float *x, int S, int D, int I, float *out){ @@ -1914,7 +2108,8 @@ static void pilot_prefetch(Model *m, int lnext, const float *x, int S){ } /* forward di UN layer (usato dai 78 principali e dal layer MTP) */ -static void layer_forward(Model *m, Layer *l, int li, float *x, int S, int pos_base, float *nrm, float *tmp){ +static void layer_forward_rows(Model *m, Layer *l, int li, float *x, int S, int pos_base, + KVState *const *kvs, const int *positions, float *nrm, float *tmp){ Cfg *c=&m->c; int D=c->hidden; if(g_spec && g_prefetch && l->sparse && m->enr[li]>0) for(int z=0;zenr[li];z++) expert_prefetch(m,li,m->eroute[li][z]); @@ -1974,7 +2169,7 @@ static void layer_forward(Model *m, Layer *l, int li, float *x, int S, int pos_b } #endif for(int s=0;sin_ln, D, c->eps); - attention(m,l,li,nrm,S,pos_base,tmp); + attention_rows(m,l,li,nrm,S,pos_base,kvs,positions,tmp); for(int64_t j=0;j<(int64_t)S*D;j++) x[j]+=tmp[j]; if(g_pilot && S<=8 && li+1n_layers && m->L[li+1].sparse) pilot_prefetch(m,li+1,x,S); if(g_looka && S==1 && li+1n_layers && m->L[li+1].sparse) la_predict(m,li+1,x,1); @@ -1982,7 +2177,11 @@ static void layer_forward(Model *m, Layer *l, int li, float *x, int S, int pos_b if(l->sparse) moe(m,l,li,nrm,S,tmp); else dense_mlp(l,nrm,S,D,c->dense_inter,tmp); for(int64_t j=0;j<(int64_t)S*D;j++) x[j]+=tmp[j]; } -static void layers_forward(Model *m, float *x, int S, int pos_base){ +static void layer_forward(Model *m, Layer *l, int li, float *x, int S, int pos_base, float *nrm, float *tmp){ + layer_forward_rows(m,l,li,x,S,pos_base,NULL,NULL,nrm,tmp); +} +static void layers_forward_rows(Model *m, float *x, int S, int pos_base, + KVState *const *kvs, const int *positions){ Cfg *c=&m->c; int D=c->hidden; if(g_pilot_real){ /* nuovo forward: il possesso-layer riparte da -1 (i layer si rifanno da 0) */ pthread_mutex_lock(&g_pilot_mx); @@ -1995,10 +2194,13 @@ static void layers_forward(Model *m, float *x, int S, int pos_base){ * puo' arrivare dopo MINUTI di streaming — al buio sembra un blocco. */ if(S>=8 && (i%4==0 || i==c->n_layers-1)) fprintf(stderr,"[prefill] layer %d/%d · %d token\n", i+1, c->n_layers, S); - layer_forward(m,&m->L[i],i,x,S,pos_base,nrm,tmp); + layer_forward_rows(m,&m->L[i],i,x,S,pos_base,kvs,positions,nrm,tmp); } free(nrm); free(tmp); } +static void layers_forward(Model *m, float *x, int S, int pos_base){ + layers_forward_rows(m,x,S,pos_base,NULL,NULL); +} static void kv_alloc(Model *m, int max_t){ Cfg *c=&m->c; @@ -2068,6 +2270,42 @@ static float *step_all(Model *m, const int *ids, int S, int pos_base){ free(x); free(row); return lo; } +/* One decode token from each independent sequence, evaluated as a single MoE + * batch. Prefill and speculative batches retain their contiguous-KV path. */ +static float *step_decode_batch(Model *m, const DecodeRow *rows, int S){ + Cfg *c=&m->c; int D=c->hidden; + /* Ragged KV currently uses MLA absorption; the stack kernel is sized to 512. */ + if(!rows || S<1 || S>64 || c->kv_lora>512) return NULL; + KVState *kvs[64]; int positions[64]; + float *x=falloc((int64_t)S*D); + for(int s=0;sLc || !rows[s].kv->Rc || !rows[s].kv->kv_start || + rows[s].token<0 || rows[s].token>=c->vocab || + rows[s].pos<0 || rows[s].pos>=rows[s].kv->max_t){ + free(x); return NULL; + } + for(int l=0;ln_layers;l++){ + if(!rows[s].kv->Lc[l] || !rows[s].kv->Rc[l] || + rows[s].kv->kv_start[l]<0 || rows[s].kv->kv_start[l]>rows[s].pos || + (m->has_dsa && c->idx_type[l] && + (!rows[s].kv->Ic || !rows[s].kv->Ic[l]))){ free(x); return NULL; } + } + for(int p=0;pfinal_norm,D,c->eps); + double th0=now_s(); + float *logit=falloc((int64_t)S*c->vocab); + matmul_qt(logit,norm,&m->lm_head,S); + m->t_head+=now_s()-th0; + free(x); free(norm); + return logit; +} + /* METODO E — prompt-lookup: cerca l'occorrenza piu' recente dell'ultimo bigramma nel * contesto e propone i token che la seguirono. Zero pesi extra, zero costo: e' solo * un'ipotesi che il modello verifichera'. */ @@ -2420,10 +2658,12 @@ static void generate(Model *m, const int *prompt, int np, int n_new, int *out){ } static void profile_print(Model *m, double elapsed){ - double accounted=m->t_edisk+m->t_emm+m->t_attn+m->t_head; - printf("PROFILE: expert-disk %.3fs | expert-matmul %.3fs | attention %.3fs " + double accounted=m->t_ewait+m->t_emm+m->t_attn+m->t_head; + printf("PROFILE: expert-disk %.3fs service / %.3fs wait | expert-matmul %.3fs | attention %.3fs " "(including kvb %.3fs) | lm_head %.3fs | other %.3fs\n", - m->t_edisk,m->t_emm,m->t_attn,m->t_kvb,m->t_head,elapsed-accounted); + m->t_edisk,m->t_ewait,m->t_emm,m->t_attn,m->t_kvb,m->t_head,elapsed-accounted); + printf("ATTENTION: projection/RoPE %.3fs | score-softmax-value %.3fs | output projection %.3fs\n", + m->t_aproj,m->t_acore,m->t_aout); #ifdef COLI_METAL if(g_metal_enabled){ uint64_t ok=0,fb=0,ex=0; double su=0,gp=0,sc=0; coli_metal_moe_counts(&ok,&fb,&ex); coli_metal_moe_times(&su,&gp,&sc); @@ -2443,7 +2683,8 @@ static void run_replay(Model *m, const int *full, int nfull, int np){ kv_alloc(m,nfull+2); float *logit=step(m,full,np-1,0); free(logit); m->hits=m->miss=m->ereq=m->gpu_expert_calls=0; - m->t_edisk=m->t_emm=m->t_attn=m->t_kvb=m->t_head=0; + m->t_edisk=m->t_ewait=m->t_emm=m->t_attn=m->t_kvb=m->t_head=0; + m->t_aproj=m->t_acore=m->t_aout=0; double t0=now_s(); int steps=0; for(int i=np-1;ipin[l]; int ids[4096], zp, eu; long g; int np=m->npin[l]; if(np>4096) np=4096; for(int z=0;zeheat[l],c->n_experts,ids,np,&zp,&eu,&g)) continue; + if(!tier_pick_lfru(m->eheat[l],m->elast[l],m->eaccess_clock, + c->n_experts,ids,np,&zp,&eu,&g)) continue; if(nbout[w].gain){ out[w].gain=g; out[w].l=l; out[w].slot=zp; out[w].eid=eu; } } @@ -2571,6 +2813,7 @@ static void repin_pass(Model *m){ +(int64_t)coli_cuda_tensor_bytes(s->u.cuda) +(int64_t)coli_cuda_tensor_bytes(s->d.cuda); m->gpu_expert_bytes+=now_gpu-old_gpu; tier="VRAM"; + if(g_cuda_release_host) expert_host_release(m,s); } else { qt_cuda_reset(&s->g); qt_cuda_reset(&s->u); qt_cuda_reset(&s->d); s->g.cuda_eligible=s->u.cuda_eligible=s->d.cuda_eligible=0; @@ -2686,6 +2929,141 @@ static void serve_ctx_free(Model *m, ServeCtx *s){ free(k->Lc); free(k->Rc); free(k->Ic); free(k->kv_start); free(s->hist); } +typedef struct { + int active, pending, emitted, maximum, prompt_tokens, length_limited; + unsigned long long id; + float temp, top_p; + double started; + uint64_t hits0, miss0; +} ServeReq; + +static void mux_data(Tok *T, unsigned long long id, int token){ + char out[256]; int n=tok_decode(T,&token,1,out,sizeof(out)); + printf("DATA %llu %d\n",id,n); if(n>0) fwrite(out,1,(size_t)n,stdout); putchar('\n'); + fflush(stdout); +} + +static void mux_done(Model *m, ServeCtx *sc, ServeReq *r){ + double dt=now_s()-r->started; if(dt<1e-6) dt=1e-6; + double dh=(double)(m->hits-r->hits0), dm=(double)(m->miss-r->miss0); + printf("DONE %llu STAT %d %.2f %.1f %.2f %d %d\n",r->id,r->emitted, + r->emitted/dt,(dh+dm)>0?100.0*dh/(dh+dm):0.0,rss_gb(), + r->prompt_tokens,r->length_limited); + fflush(stdout); kv_bind(m,&sc->kv); kv_disk_append(m,sc->hist,sc->len); + r->active=0; +} + +/* Read and prefill one request. Returns -1 on EOF, 0 for a rejected frame and + * 1 for an accepted request. Prefill deliberately remains serial: continuous + * batching starts at decode, where every active slot contributes one row. */ +static int mux_submit(Model *m, Tok *T, ServeCtx *ctx, ServeReq *req, int nctx, + int maxctx, int eos){ + char *line=NULL; size_t cap=0; ssize_t nr=getline(&line,&cap,stdin); + if(nr<0){ free(line); return -1; } + if(nr && line[nr-1]=='\n') line[--nr]=0; + if(!strncmp(line,"CANCEL ",7)){ + unsigned long long id=0; char tail; + if(sscanf(line+7,"%llu %c",&id,&tail)!=1 || id==0){ + printf("ERROR 0 BAD_REQUEST\n"); fflush(stdout); free(line); return 0; + } + for(int i=0;i=nctx || memchr(raw,0,(size_t)sub.bytes)){ + printf("ERROR %llu BAD_REQUEST\n",sub.id); fflush(stdout); free(raw); free(line); return 0; + } + if(req[sub.slot].active){ + printf("ERROR %llu SLOT_BUSY\n",sub.id); fflush(stdout); free(raw); free(line); return 0; + } + for(int i=0;ikv); + int *tmp=malloc(maxctx*sizeof(int)); + int nt=tok_encode(T,raw,(int)sub.bytes,tmp,maxctx-2); + free(raw); free(line); + if(nt<1){ free(tmp); printf("ERROR %llu EMPTY_PROMPT\n",sub.id); fflush(stdout); return 0; } + int prefix=0; while(prefixlen && prefixhist[prefix]==tmp[prefix]) prefix++; + if(prefixlen){ sc->len=prefix; if(m->has_mtp) m->kv_start[m->c.n_layers]=-1; + kv_disk_truncate(m,sc->len); } + int add=nt-sc->len; + if(add>0) memcpy(sc->hist+sc->len,tmp+sc->len,(size_t)add*sizeof(int)); + free(tmp); + float *logit = add>0 ? step(m,sc->hist+sc->len,add,sc->len) + : step(m,sc->hist+sc->len-1,1,sc->len-1); + sc->len+=add; sc->first=0; + ServeReq *r=&req[sub.slot]; memset(r,0,sizeof(*r)); + r->id=sub.id; r->maximum=sub.max_tokens; r->temp=sub.temperature; r->top_p=sub.top_p; + r->prompt_tokens=nt; r->started=now_s(); r->hits0=m->hits; r->miss0=m->miss; + int room=maxctx-sc->len-1; if(r->maximum>room){r->maximum=room; r->length_limited=1;} + g_temp=r->temp; g_nuc=r->top_p; + int next=pick_tok(logit,m->c.vocab,-1); free(logit); + if(r->maximum<=0 || next==eos || is_stop(next)){ mux_done(m,sc,r); return 1; } + r->pending=next; r->emitted=1; r->active=1; sc->hist[sc->len]=next; m->n_emit++; + mux_data(T,r->id,next); + if(r->emitted>=r->maximum) mux_done(m,sc,r); + return 1; +} + +static void run_serve_mux(Model *m, const char *snap){ + char tkp[2048]; snprintf(tkp,sizeof(tkp),"%s/tokenizer.json",snap); + Tok T; tok_load(&T,tkp); int eos=tok_id_of(&T,"<|endoftext|>"); stops_arm(&m->c,eos); + g_draft=0; /* one scheduler owns every forward; MTP/speculation is not ragged-safe */ + int maxctx=getenv("CTX")?atoi(getenv("CTX")):4096; + int nctx=getenv("KV_SLOTS")?atoi(getenv("KV_SLOTS")):1; + if(nctx<1||nctx>16){fprintf(stderr,"KV_SLOTS deve essere tra 1 e 16\n");exit(2);} + g_kvsave=getenv("KVSAVE")?atoi(getenv("KVSAVE")):1; + KVState *initial=m->kv; free(initial->kv_start); free(initial); + ServeCtx *ctx=calloc(nctx,sizeof(*ctx)); ServeReq *req=calloc(nctx,sizeof(*req)); + for(int i=0;i0 && FD_ISSET(STDIN_FILENO,&rfds)) if(mux_submit(m,&T,ctx,req,nctx,maxctx,eos)<0) eof=1; + active=0; for(int i=0;in_fw++; + for(int s=0;slen++; g_temp=r->temp; g_nuc=r->top_p; + int next=pick_tok(lo+(int64_t)s*m->c.vocab,m->c.vocab,-1); + if(next==eos || is_stop(next)){mux_done(m,sc,r);continue;} + r->pending=next; sc->hist[sc->len]=next; r->emitted++; m->n_emit++; + mux_data(&T,r->id,next); + if(r->emitted>=r->maximum) mux_done(m,sc,r); + } + free(lo); + } + usage_save(m); + for(int i=0;ikv=NULL; m->Lc=m->Rc=m->Ic=NULL; m->kv_start=NULL; m->max_t=0; +} + static void run_serve(Model *m, const char *snap){ char tkp[2048]; snprintf(tkp,sizeof(tkp),"%s/tokenizer.json",snap); Tok T; tok_load(&T,tkp); @@ -2811,6 +3189,7 @@ static void run_serve(Model *m, const char *snap){ free(raw); g_temp=base_temp; g_nuc=base_nuc; usage_save(m); /* la cache che impara: storia aggiornata a ogni turno */ kv_disk_append(m,hist,len); /* KV su disco: il prossimo avvio riparte da qui */ + repin_pass(m); /* safe request boundary: adapt session-local hot tier */ } free(line); free(buf); usage_save(m); @@ -2922,60 +3301,72 @@ static void pin_wire(Model *m){ "(no compression) in %.0fs\n", wired/1e9, now_s()-t0); } +typedef struct { int l,e; uint32_t c; } PinRec; +static int pin_rec_cmp(const void *a,const void *b){ + const PinRec *x=a,*y=b; return x->cc?1:x->c>y->c?-1:0; +} static void pin_load(Model *m, const char *statspath, double gb){ FILE *f=fopen(statspath,"r"); if(!f){ perror(statspath); return; } - typedef struct { int l,e; uint32_t c; } Rec; Cfg *c=&m->c; int cap=(c->n_layers+1)*c->n_experts; - Rec *r=malloc((size_t)cap*sizeof(Rec)); int n=0; + PinRec *r=malloc((size_t)cap*sizeof(PinRec)); int n=0; + unsigned char *seen=calloc((size_t)(c->n_layers+1)*c->n_experts,1); int l,e; uint32_t cnt; while(n=0 && e>=0 && en_experts && ((ln_layers && m->L[l].sparse) || (l==c->n_layers && m->has_mtp)); - if(ok) r[n++]=(Rec){l,e,cnt}; + int64_t key=(int64_t)l*c->n_experts+e; + if(ok&&!seen[key]){ r[n++]=(PinRec){l,e,cnt}; seen[key]=1; } } fclose(f); - for(int a=0;ar[best].c) best=b; - Rec t=r[a]; r[a]=r[best]; r[best]=t; - if(a>4095) break; /* bastano i top ~4k */ + int fill=getenv("PIN_FILL")?atoi(getenv("PIN_FILL")):0; +#ifdef COLI_CUDA + if(!getenv("PIN_FILL")&&g_cuda_release_host) fill=1; +#endif + if(fill) for(int li=0;li<=c->n_layers;li++){ + int sparse=(lin_layers&&m->L[li].sparse)||(li==c->n_layers&&m->has_mtp); + if(sparse) for(int ei=0;ein_experts;ei++) if(!seen[(int64_t)li*c->n_experts+ei]) + r[n++]=(PinRec){li,ei,0}; } + free(seen); + qsort(r,(size_t)n,sizeof(*r),pin_rec_cmp); int64_t eb=expert_bytes_probe(m,m->ebits); - int npin=(int)(gb*1e9/eb); if(npin>n) npin=n; if(npin>4096) npin=4096; + int npin=(int)(gb*1e9/eb); if(npin>n) npin=n; if(npin<1){ free(r); return; } int *cnt_l=calloc(c->n_layers+1,sizeof(int)); /* +1: riga MTP */ for(int a=0;an_layers;i++) if(cnt_l[i]) m->pin[i]=calloc(cnt_l[i],sizeof(ESlot)); + int *slot_of=malloc((size_t)npin*sizeof(int)), *next=calloc(c->n_layers+1,sizeof(int)); + for(int a=0;an_layers;i++) m->npin[i]=cnt_l[i]; double t0=now_s(); - #pragma omp parallel for schedule(dynamic,1) - for(int a=0;anpin[li]++; - expert_load(m,li,r[a].e,&m->pin[li][slot],1); +#ifdef COLI_CUDA + double remaining[COLI_CUDA_MAX_DEVICES]={0}, placed_b[COLI_CUDA_MAX_DEVICES]={0}; + int placed_n[COLI_CUDA_MAX_DEVICES]={0}, gpu_prefix=0; + double budget=g_cuda_expert_gb*1e9, safe_total=0; + if(g_cuda_enabled&&g_cuda_expert_gb>0) for(int i=0;iresident_bytes += (int64_t)npin*eb; - fprintf(stderr,"[PIN] hot store: %d experts in RAM (%.1f GB) loaded in %.0fs from %s\n", - npin, npin*eb/1e9, now_s()-t0, statspath); + if(budget>safe_total) budget=safe_total; + if(g_cuda_enabled&&g_cuda_release_host&&budget>0){ gpu_prefix=(int)(budget/eb)+g_cuda_ndev; if(gpu_prefix>npin)gpu_prefix=npin; } +#else + int gpu_prefix=0; +#endif + /* Load the VRAM-ranked prefix first. Once uploaded its host backing is + * released before the disjoint RAM-ranked suffix is allocated. */ + #pragma omp parallel for schedule(dynamic,1) + for(int a=0;a<(gpu_prefix?gpu_prefix:npin);a++) + expert_load(m,r[a].l,r[a].e,&m->pin[r[a].l][slot_of[a]],1); + m->resident_bytes+=(int64_t)(gpu_prefix?gpu_prefix:npin)*eb; #ifdef COLI_CUDA if(g_cuda_enabled && g_cuda_expert_gb>0){ - double remaining[COLI_CUDA_MAX_DEVICES]={0}, placed_b[COLI_CUDA_MAX_DEVICES]={0}; - int placed_n[COLI_CUDA_MAX_DEVICES]={0}; - double budget=g_cuda_expert_gb*1e9, safe_total=0; - for(int i=0;isafe_total) budget=safe_total; - for(int a=0;agpu_expert_bytesgpu_expert_bytesnpin[li];z++) if(m->pin[li][z].eid==r[a].e){ - ESlot *s=&m->pin[li][z]; + { ESlot *s=&m->pin[li][slot_of[a]]; int64_t need=qt_bytes(&s->g)+qt_bytes(&s->u)+qt_bytes(&s->d); if(m->gpu_expert_bytes+need>budget) break; int tried[COLI_CUDA_MAX_DEVICES]={0}, placed=0; @@ -2993,6 +3384,7 @@ static void pin_load(Model *m, const char *statspath, double gb){ +(int64_t)coli_cuda_tensor_bytes(s->d.cuda); m->gpu_expert_count++; m->gpu_expert_bytes+=actual; remaining[best]-=actual; placed_b[best]+=actual; placed_n[best]++; + if(g_cuda_release_host) expert_host_release(m,s); placed=1; } else { qt_cuda_reset(&s->g); qt_cuda_reset(&s->u); qt_cuda_reset(&s->d); @@ -3000,7 +3392,6 @@ static void pin_load(Model *m, const char *statspath, double gb){ remaining[best]=0; /* device rejected its projected capacity */ } } - break; } } fprintf(stderr,"[CUDA] hot expert tier: %d/%d experts, VRAM %.2f GB (total budget %.1f GB)\n", @@ -3009,8 +3400,16 @@ static void pin_load(Model *m, const char *statspath, double gb){ g_cuda_devices[i],placed_n[i],placed_b[i]/1e9); } #endif + if(gpu_prefix>0&&gpu_prefixpin[r[a].l][slot_of[a]],1); + m->resident_bytes+=(int64_t)(npin-gpu_prefix)*eb; + } + fprintf(stderr,"[PIN] placement: %d VRAM + %d RAM expert (%.1f GB warm) in %.0fs da %s\n", + m->gpu_expert_count,npin-m->gpu_expert_count,(npin-m->gpu_expert_count)*eb/1e9,now_s()-t0,statspath); pin_wire(m); /* inchioda in RAM (no compressione) / wire in RAM (no compression) */ - free(r); free(cnt_l); + free(r); free(cnt_l); free(slot_of); free(next); } static double g_mem_avail_boot=0; /* MemAvailable all'avvio, prima di caricare il modello */ @@ -3158,6 +3557,14 @@ int main(int argc, char **argv){ if(g_mmap) fprintf(stderr,"[MMAP] expert = viste zero-copy nei file (page cache = cache)\n"); g_topk = getenv("TOPK")?atoi(getenv("TOPK")):0; g_topp = getenv("TOPP")?atof(getenv("TOPP")):0; + const char *policy=getenv("COLI_POLICY"); if(!policy) policy="quality"; + int experimental=!strcmp(policy,"experimental-fast"); + if(strcmp(policy,"quality")&&strcmp(policy,"balanced")&&!experimental){ + fprintf(stderr,"COLI_POLICY non valida: quality, balanced o experimental-fast\n"); return 2; + } + if(!experimental&&(g_topk>0||g_topp>0)){ + fprintf(stderr,"[policy] --topp/--topk drop low-weight experts (~1.6x fewer reads, small quality cost)\n"); + } g_mlock = getenv("MLOCK")?atoi(getenv("MLOCK")):-1; /* -1 auto (ON macOS), 0 off, 1 force / auto (ON macOS), 0 off, 1 force */ g_spec = getenv("SPEC")?atoi(getenv("SPEC")):1; g_draft = getenv("DRAFT")?atoi(getenv("DRAFT")):-1; /* -1 = auto: 3 se MTP, 0 senza */ @@ -3203,10 +3610,13 @@ int main(int argc, char **argv){ } g_cuda_dense=getenv("CUDA_DENSE")?atoi(getenv("CUDA_DENSE")):0; g_cuda_expert_gb=getenv("CUDA_EXPERT_GB")?atof(getenv("CUDA_EXPERT_GB")):0; + g_cuda_release_host=getenv("CUDA_RELEASE_HOST")?atoi(getenv("CUDA_RELEASE_HOST")):(g_cuda_ndev>1); if((getenv("COLI_GPU")||getenv("COLI_GPUS"))&&!g_cuda_enabled){ fprintf(stderr,"COLI_GPU(S) requires COLI_CUDA=1\n"); return 2; } if(g_cuda_dense&&!g_cuda_enabled){ fprintf(stderr,"CUDA_DENSE requires COLI_CUDA=1\n"); return 2; } if(g_cuda_expert_gb>0 && !g_cuda_enabled){ fprintf(stderr,"CUDA_EXPERT_GB requires COLI_CUDA=1\n"); return 2; } - if(g_cuda_enabled) fprintf(stderr,"[CUDA] mode: routed experts%s\n",g_cuda_dense?" + resident dense tensors":" only (resident dense on CPU)"); + if(g_cuda_enabled) fprintf(stderr,"[CUDA] mode: routed experts%s%s\n", + g_cuda_dense?" + resident dense tensors":" only (resident dense on CPU)", + g_cuda_release_host?"; VRAM experts without host backing":""); #else if((getenv("COLI_CUDA") && atoi(getenv("COLI_CUDA"))) || getenv("COLI_GPU") || getenv("COLI_GPUS") || @@ -3233,7 +3643,13 @@ int main(int argc, char **argv){ printf("== GLM C engine (glm_moe_dsa), cache=%d experts/layer | experts@%d-bit dense@%d-bit | idot: " IDOT_KERNEL " ==\n", cap, ebits, dbits); g_mem_avail_boot = mem_available_gb(); Model m; double t0=now_s(); model_init(&m,snap,cap,ebits,dbits); - if(g_draft<0) g_draft = m.has_mtp ? 3 : 0; + if(g_draft<0){ +#ifdef COLI_CUDA + g_draft = (m.has_mtp&&!g_cuda_enabled) ? 3 : 0; +#else + g_draft = m.has_mtp ? 3 : 0; +#endif + } if(getenv("DSA_TOPK")) m.c.index_topk=atoi(getenv("DSA_TOPK")); /* override per test */ printf("loaded in %.2fs | resident dense: %.2f MB | layers=%d experts=%d | MTP %s (draft=%d)\n", now_s()-t0, m.resident_bytes/(1024.0*1024.0), m.c.n_layers, m.c.n_experts, @@ -3272,7 +3688,11 @@ int main(int argc, char **argv){ if(getenv("SCORE")){ run_score(&m, getenv("SCORE")); if(stats) stats_dump(&m,stats); return 0; } /* modo serve persistente per la CLI 'coli': SERVE=1 */ - if(getenv("SERVE")){ run_serve(&m, snap); if(stats) stats_dump(&m,stats); return 0; } + if(getenv("SERVE")){ + if(getenv("SERVE_BATCH") && atoi(getenv("SERVE_BATCH"))) run_serve_mux(&m,snap); + else run_serve(&m,snap); + if(stats) stats_dump(&m,stats); return 0; + } /* modo testo reale: PROMPT="..." [NGEN=n] -> tokenizza, genera, detokenizza */ if(getenv("PROMPT")){ diff --git a/c/openai_server.py b/c/openai_server.py index 0c26b78..47672d0 100644 --- a/c/openai_server.py +++ b/c/openai_server.py @@ -6,8 +6,10 @@ import codecs import collections import contextlib import json +import math import os import select +import queue import signal import socket import subprocess @@ -54,18 +56,22 @@ def error_object(error): class GenerationScheduler: - """Bounded FIFO admission for the engine's single mutable KV context.""" + """Bounded FIFO admission for the engine's independent KV contexts.""" - def __init__(self, max_queue=8, queue_timeout=300): + def __init__(self, max_queue=8, queue_timeout=300, capacity=1): if max_queue < 0: raise ValueError("max_queue cannot be negative") if queue_timeout <= 0: raise ValueError("queue_timeout must be positive") + if capacity < 1: + raise ValueError("capacity must be positive") self.max_queue = max_queue self.queue_timeout = queue_timeout + self.capacity = capacity + self.free_slots = set(range(capacity)) self.condition = threading.Condition() self.queue = collections.deque() - self.active = False + self.active = 0 self.closed = False self.admitted = 0 self.completed = 0 @@ -74,14 +80,14 @@ class GenerationScheduler: self.cancelled = 0 @contextlib.contextmanager - def admit(self, cancelled=None): + def admit(self, cancelled=None, slot=None): ticket = object() queued_at = time.monotonic() with self.condition: if self.closed: raise APIError(503, "The inference scheduler is shutting down.", None, "scheduler_closed", "server_error") - if (self.active or self.queue) and len(self.queue) >= self.max_queue: + if (self.active >= self.capacity or self.queue) and len(self.queue) >= self.max_queue: self.rejected += 1 raise APIError(429, "The inference queue is full.", None, "queue_full", "rate_limit_error", {"Retry-After": "1"}) @@ -93,7 +99,8 @@ class GenerationScheduler: self.condition.notify_all() raise APIError(503, "The inference scheduler is shutting down.", None, "scheduler_closed", "server_error") - if not self.active and self.queue[0] is ticket: + available = min(self.free_slots) if slot is None and self.free_slots else slot + if self.queue[0] is ticket and available in self.free_slots: break if cancelled and cancelled(): self.queue.remove(ticket) @@ -109,20 +116,23 @@ class GenerationScheduler: "queue_timeout", "rate_limit_error", {"Retry-After": "1"}) self.condition.wait(min(remaining, 0.25)) self.queue.popleft() - self.active = True + self.free_slots.remove(available) + self.active += 1 self.admitted += 1 wait_seconds = time.monotonic() - queued_at try: - yield wait_seconds + yield wait_seconds, available finally: with self.condition: - self.active = False + self.active -= 1 + self.free_slots.add(available) self.completed += 1 self.condition.notify_all() def snapshot(self): with self.condition: return {"active": self.active, "queued": len(self.queue), + "capacity": self.capacity, "max_queue": self.max_queue, "queue_timeout_seconds": self.queue_timeout, "admitted": self.admitted, "completed": self.completed, "rejected": self.rejected, "timed_out": self.timed_out, @@ -325,9 +335,11 @@ def generation_options(body, limit): top_p = 0.9 if top_p is None else top_p if isinstance(maximum, bool) or not isinstance(maximum, int) or not 1 <= maximum <= limit: raise APIError(400, f"`{maximum_param}` must be an integer between 1 and {limit}.", maximum_param) - if isinstance(temperature, bool) or not isinstance(temperature, (int, float)) or not 0 <= temperature <= 2: + if (isinstance(temperature, bool) or not isinstance(temperature, (int, float)) or + not math.isfinite(temperature) or not 0 <= temperature <= 2): raise APIError(400, "`temperature` must be between 0 and 2.", "temperature") - if isinstance(top_p, bool) or not isinstance(top_p, (int, float)) or not 0 < top_p <= 1: + if (isinstance(top_p, bool) or not isinstance(top_p, (int, float)) or + not math.isfinite(top_p) or not 0 < top_p <= 1): raise APIError(400, "`top_p` must be greater than 0 and at most 1.", "top_p") return maximum, float(temperature), float(top_p) @@ -363,17 +375,100 @@ def read_engine_turn(stream, sentinel, on_bytes): class Engine: def __init__(self, executable, model, cap=8, max_tokens=1024, env=None, kv_slots=1): - child_env = dict(env or os.environ, SNAP=str(model), SERVE="1", NGEN=str(max_tokens), - KV_SLOTS=str(kv_slots)) + child_env = dict(env or os.environ, SNAP=str(model), SERVE="1", SERVE_BATCH="1", + NGEN=str(max_tokens), KV_SLOTS=str(kv_slots)) self.process = subprocess.Popen( [str(executable), str(cap)], env=child_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, bufsize=0, ) - self.lock = threading.Lock() + self.write_lock = threading.Lock() + self.pending_lock = threading.Lock() + self.pending = {} + self.next_request_id = 1 + self.closed = False + self.dispatcher_error = None self.kv_slots = kv_slots read_engine_turn(self.process.stdout, READY, lambda _: None) + self.dispatcher = threading.Thread(target=self._dispatch_stdout, + name="colibri-stdout", daemon=True) + self.dispatcher.start() - def generate(self, prompt, max_tokens, temperature, top_p, on_text, cache_slot=0): + @staticmethod + def _stats(fields): + if len(fields) < 5 or fields[0] != "STAT": + raise RuntimeError(f"invalid engine status: {' '.join(fields)}") + return { + "completion_tokens": int(fields[1]), + "tokens_per_second": float(fields[2]), + "cache_hit_percent": float(fields[3]), + "rss_gb": float(fields[4]), + "prompt_tokens": int(fields[5]) if len(fields) > 5 else 0, + "length_limited": bool(int(fields[6])) if len(fields) > 6 else False, + } + + def _fail_pending(self, error): + with self.pending_lock: + requests = list(self.pending.values()) + self.pending.clear() + for events in requests: + events.put(("error", error)) + + def _read_exact(self, size): + chunks = [] + remaining = size + while remaining: + chunk = self.process.stdout.read(remaining) + if chunk == b"": + raise RuntimeError("truncated engine DATA payload") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + def _dispatch_stdout(self): + try: + while True: + line = self.process.stdout.readline() + if line == b"": + raise RuntimeError("colibri engine exited unexpectedly") + fields = line.decode("utf-8", "replace").strip().split() + if not fields: + continue + kind = fields[0] + if kind == "DATA" and len(fields) == 3: + request_id = fields[1] + size = int(fields[2]) + if not 0 <= size <= 65536: + raise RuntimeError("invalid engine DATA size") + data = self._read_exact(size) + if self._read_exact(1) != b"\n": + raise RuntimeError("invalid engine DATA terminator") + with self.pending_lock: + events = self.pending.get(request_id) + if events is not None: + events.put(("data", data)) + elif kind == "DONE" and len(fields) >= 7: + request_id = fields[1] + stats = self._stats(fields[2:]) + with self.pending_lock: + events = self.pending.pop(request_id, None) + if events is not None: + events.put(("done", stats)) + elif kind == "ERROR" and len(fields) >= 2: + request_id = fields[1] + message = " ".join(fields[2:]) or "engine request failed" + with self.pending_lock: + events = self.pending.pop(request_id, None) + if events is not None: + events.put(("error", RuntimeError(message))) + else: + raise RuntimeError(f"invalid engine response: {' '.join(fields)}") + except Exception as error: + if not self.closed: + self.dispatcher_error = error + self._fail_pending(error) + + def generate(self, prompt, max_tokens, temperature, top_p, on_text, cache_slot=0, + cancelled=None): if isinstance(cache_slot, bool) or not isinstance(cache_slot, int) or not 0 <= cache_slot < self.kv_slots: raise APIError(400, "Invalid cache slot.", "cache_slot") payload = prompt.encode("utf-8") @@ -386,26 +481,66 @@ class Engine: if text: on_text(text) - with self.lock: + events = queue.Queue() + with self.pending_lock: + if self.closed: + raise RuntimeError("colibri engine is shutting down") + if self.dispatcher_error is not None: + raise RuntimeError("colibri engine dispatcher stopped") from self.dispatcher_error if self.process.poll() is not None: raise RuntimeError("colibri engine is not running") - header = (f"\x02PROMPT {len(payload)} {max_tokens} {temperature:.8g} " - f"{top_p:.8g} {cache_slot}\n").encode() - self.process.stdin.write(header + payload + b"\n") - self.process.stdin.flush() - stats = read_engine_turn(self.process.stdout, END, decode) - tail = decoder.decode(b"", final=True) - if tail: - on_text(tail) - return stats + request_id = str(self.next_request_id) + self.next_request_id += 1 + self.pending[request_id] = events + header = (f"SUBMIT {request_id} {cache_slot} {len(payload)} {max_tokens} " + f"{temperature:.8g} {top_p:.8g}\n").encode() + try: + with self.write_lock: + if self.process.poll() is not None: + raise RuntimeError("colibri engine is not running") + self.process.stdin.write(header + payload + b"\n") + self.process.stdin.flush() + except Exception: + with self.pending_lock: + self.pending.pop(request_id, None) + raise + + cancel_sent = False + while True: + kind, value = events.get() + if kind == "data": + if not cancel_sent: + decode(value) + if cancelled and cancelled(): + cancel_sent = True + with self.write_lock: + self.process.stdin.write(f"CANCEL {request_id}\n".encode()) + self.process.stdin.flush() + elif kind == "done": + tail = decoder.decode(b"", final=True) + if tail: + on_text(tail) + return value + elif cancel_sent and isinstance(value, RuntimeError) and str(value) == "CANCELLED": + raise ClientCancelled() + else: + raise value def close(self): + with self.pending_lock: + if self.closed: + return + self.closed = True + self._fail_pending(RuntimeError("colibri engine is shutting down")) if self.process.poll() is None: self.process.terminate() try: self.process.wait(timeout=5) except subprocess.TimeoutExpired: self.process.kill() + self.process.wait(timeout=5) + if self.dispatcher is not threading.current_thread(): + self.dispatcher.join(timeout=5) def model_object(model_id, created): @@ -423,7 +558,7 @@ class APIServer(ThreadingHTTPServer): self.model_id = model_id self.api_key = api_key self.max_tokens = max_tokens - self.scheduler = GenerationScheduler(max_queue, queue_timeout) + self.scheduler = GenerationScheduler(max_queue, queue_timeout, kv_slots) self.kv_slots = kv_slots self.cors_origins = tuple(cors_origins) self.created = int(time.time()) @@ -543,8 +678,10 @@ class APIHandler(BaseHTTPRequestHandler): def generation(self, body, prompt, request_id, chat): maximum, temperature, top_p = generation_options(body, self.server.max_tokens) tools = (body.get("tools") or body.get("functions") or None) if chat else None - cache_slot = body.get("cache_slot", 0) - if isinstance(cache_slot, bool) or not isinstance(cache_slot, int) or not 0 <= cache_slot < self.server.kv_slots: + cache_slot = body.get("cache_slot") + if (cache_slot is not None and + (isinstance(cache_slot, bool) or not isinstance(cache_slot, int) or + not 0 <= cache_slot < self.server.kv_slots)): raise APIError(400, f"`cache_slot` must be an integer between 0 and {self.server.kv_slots - 1}.", "cache_slot") stream = body.get("stream", False) @@ -559,12 +696,14 @@ class APIHandler(BaseHTTPRequestHandler): completion_id = id_prefix + uuid.uuid4().hex created = int(time.time()) - with self.server.scheduler.admit(self.client_disconnected) as queue_wait: + with self.server.scheduler.admit(self.client_disconnected, cache_slot) as admission: + queue_wait, cache_slot = admission queue_headers = {"x-colibri-queue-wait-ms": str(round(queue_wait * 1000))} if not stream: output = [] stats = self.server.engine.generate( - prompt, maximum, temperature, top_p, output.append, cache_slot) + prompt, maximum, temperature, top_p, output.append, cache_slot, + self.client_disconnected) text = "".join(output) length_finish = "length" if stats["length_limited"] else "stop" if chat and tools: @@ -670,7 +809,8 @@ class APIHandler(BaseHTTPRequestHandler): emit(sp["buf"][:flush]) sp["buf"] = sp["buf"][flush:] stats = self.server.engine.generate( - prompt, maximum, temperature, top_p, emit_tools, cache_slot) + prompt, maximum, temperature, top_p, emit_tools, cache_slot, + lambda: not connected) if not sp["tool"] and sp["buf"]: emit(sp["buf"]) # no tool call happened: flush held tail _content, calls = parse_tool_calls("".join(raw), tools) @@ -686,7 +826,8 @@ class APIHandler(BaseHTTPRequestHandler): sys.stderr.write(chunk); sys.stderr.flush() emit(chunk) stats = self.server.engine.generate( - prompt, maximum, temperature, top_p, emit_plain, cache_slot) + prompt, maximum, temperature, top_p, emit_plain, cache_slot, + lambda: not connected) finish = "length" if stats["length_limited"] else "stop" ka_stop.set() # generation done: stop the keepalive pump ka_thread.join(timeout=2) @@ -762,20 +903,25 @@ def serve(model, host="127.0.0.1", port=8000, model_id="glm-5.2-colibri", api_ke raise ValueError("kv_slots must be between 1 and 16") if host not in ("127.0.0.1", "localhost", "::1") and not api_key: print("WARNING: API is listening beyond localhost without COLI_API_KEY", file=sys.stderr) - runtime = Engine(engine,model,cap,max_tokens,env,kv_slots) origins = DEFAULT_CORS_ORIGINS if cors_origins is None else tuple(cors_origins) - server = APIServer((host, port), runtime, model_id, api_key, max_tokens, origins, + # Bind before starting the 744B engine. A stale/occupied port must fail in + # milliseconds rather than loading hundreds of GB and leaking a child. + server = APIServer((host, port), None, model_id, api_key, max_tokens, origins, max_queue, queue_timeout, kv_slots) - print(f"OpenAI-compatible API listening on http://{host}:{port}/v1", file=sys.stderr) + runtime = None previous_sigterm = signal.getsignal(signal.SIGTERM) - signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown, daemon=True).start()) try: + runtime = Engine(engine,model,cap,max_tokens,env,kv_slots) + server.engine = runtime + print(f"OpenAI-compatible API listening on http://{host}:{port}/v1", file=sys.stderr) + signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown, daemon=True).start()) server.serve_forever() finally: signal.signal(signal.SIGTERM, previous_sigterm) server.scheduler.close() server.server_close() - runtime.close() + if runtime is not None: + runtime.close() def main(): diff --git a/c/resource_plan.py b/c/resource_plan.py index 22ac79e..1c3a572 100644 --- a/c/resource_plan.py +++ b/c/resource_plan.py @@ -105,9 +105,33 @@ def discover_gpus(): return devices +def physical_cpu_count(): + try: + result = subprocess.run(["lscpu", "-p=core,socket"], text=True, + capture_output=True, check=True, timeout=5) + cores = {tuple(map(int, line.split(","))) for line in result.stdout.splitlines() + if line and not line.startswith("#")} + if cores: + return len(cores) + except (OSError, ValueError, subprocess.SubprocessError): + pass + return os.cpu_count() or 1 + + +POLICIES = { + "quality": {"preserve_quantization": True, "preserve_router": True}, + "balanced": {"preserve_quantization": True, "preserve_router": True}, + "experimental-fast": {"preserve_quantization": False, "preserve_router": False}, +} + + def build_plan(model, ram_gb=0, context=4096, gpu_indices=None, vram_gb=0, - available_memory=None, available_disk=None, gpus=None): + available_memory=None, available_disk=None, gpus=None, + policy="quality", physical_cpus=None): + if policy not in POLICIES: + raise ValueError(f"unknown policy: {policy}") info = analyze_model(model) + physical_cpus = physical_cpu_count() if physical_cpus is None else physical_cpus cfg = info["config"] available_memory = memory_available() if available_memory is None else available_memory if available_disk is None: @@ -146,8 +170,13 @@ def build_plan(model, ram_gb=0, context=4096, gpu_indices=None, vram_gb=0, safe_vram += usable gpu_plan.append(dict(gpu, reserve_bytes=reserve, usable_bytes=usable)) requested_vram = int(vram_gb * GB) if vram_gb > 0 else safe_vram - vram_budget = min(requested_vram, safe_vram, cache_bytes) + # VRAM-resident experts do not need duplicate RAM backing: the checkpoint is + # their recovery source. RAM is therefore an independent warm compute tier. + vram_budget = min(requested_vram, safe_vram, info["expert_bytes"]) vram_experts = int(vram_budget // typical) if typical else 0 + hot_bytes = min(info["expert_bytes"], vram_experts * typical) + warm_bytes = min(max(0, info["expert_bytes"] - hot_bytes), cache_bytes) + cold_bytes = max(0, info["expert_bytes"] - hot_bytes - warm_bytes) warnings = [] if cap < 1: @@ -155,21 +184,41 @@ def build_plan(model, ram_gb=0, context=4096, gpu_indices=None, vram_gb=0, if gpu_indices is not None and len(gpus) != len(set(gpu_indices)): warnings.append("one or more requested GPUs were not detected") if gpus and vram_budget < requested_vram: - warnings.append("VRAM tier was clamped by free VRAM or its required RAM backing") + warnings.append("VRAM tier was clamped by free VRAM or model expert size") + if cold_bytes: + warnings.append("cold expert misses may reach disk; normal decode speed depends on hit rate") + + if cold_bytes: + bottleneck = "disk expert misses" + elif warm_bytes: + bottleneck = "CPU expert compute and RAM bandwidth" + else: + bottleneck = "GPU compute and interconnect" return { - "version": 1, + "version": 2, + "policy": {"name": policy, **POLICIES[policy], + "quality_preserving": policy != "experimental-fast"}, "model": {key: value for key, value in info.items() if key != "config"}, + "cpu": {"physical_cores": max(1, int(physical_cpus)), + "thread_policy": "physical-cores"}, "tiers": { - "disk": {"role": "backing", "model_bytes": info["model_bytes"], - "available_bytes": available_disk}, - "ram": {"role": "resident+cache", "available_bytes": available_memory, + "disk": {"role": "cold-backing", "model_bytes": info["model_bytes"], + "available_bytes": available_disk, "cold_expert_bytes": cold_bytes}, + "ram": {"role": "resident+warm-experts", "available_bytes": available_memory, "budget_bytes": ram_budget, "dense_bytes": info["dense_bytes"], "runtime_bytes": runtime_bytes, "expert_cache_bytes": cache_bytes, - "cache_slots_per_layer": cap}, + "warm_expert_bytes": warm_bytes, "cache_slots_per_layer": cap}, "vram": {"role": "hot-experts", "devices": gpu_plan, - "budget_bytes": vram_budget, "expert_capacity": vram_experts}, + "budget_bytes": vram_budget, "hot_expert_bytes": hot_bytes, + "expert_capacity": vram_experts, "requires_host_backing": False}, }, + "expected_bottleneck": bottleneck, + "decisions": [ + {"target": "VRAM", "reason": "profile-ranked hot experts"}, + {"target": "RAM", "reason": "warm experts execute on CPU without quality loss"}, + {"target": "Disk", "reason": "immutable recovery source for cold experts"}, + ], "warnings": warnings, } @@ -177,6 +226,12 @@ def build_plan(model, ram_gb=0, context=4096, gpu_indices=None, vram_gb=0, def environment_for_plan(plan, env=None, cuda_enabled=True): """Apply a plan without overriding explicit user environment settings.""" result = dict(env or {}) + result.setdefault("COLI_POLICY", plan["policy"]["name"]) + result.setdefault("OMP_NUM_THREADS", str(plan["cpu"]["physical_cores"])) + result.setdefault("OMP_PROC_BIND", "spread") + result.setdefault("OMP_PLACES", "cores") + if plan["policy"]["name"] == "balanced": + result.setdefault("REPIN", "64") ram = plan["tiers"]["ram"] result.setdefault("RAM_GB", f"{ram['budget_bytes'] / GB:.3f}") @@ -203,11 +258,15 @@ def format_bytes(value): def format_plan(plan): model, tiers = plan["model"], plan["tiers"] - lines = [f"model {model['shards']} shards · {format_bytes(model['model_bytes'])}", - f"disk backing store · {format_bytes(tiers['disk']['available_bytes'])} free", + policy=plan["policy"] + lines = [f"policy {policy['name']} · quality-preserving {'yes' if policy['quality_preserving'] else 'no'}", + f"model {model['shards']} shards · {format_bytes(model['model_bytes'])}", + f"disk {format_bytes(tiers['disk']['cold_expert_bytes'])} cold experts · " + f"{format_bytes(tiers['disk']['available_bytes'])} free", f"RAM {format_bytes(tiers['ram']['budget_bytes'])} budget · " f"{format_bytes(tiers['ram']['dense_bytes'])} dense · " f"{format_bytes(tiers['ram']['runtime_bytes'])} runtime · " + f"{format_bytes(tiers['ram']['warm_expert_bytes'])} warm experts · " f"cap {tiers['ram']['cache_slots_per_layer']}/layer"] vram = tiers["vram"] if vram["devices"]: @@ -216,5 +275,6 @@ def format_plan(plan): f"~{vram['expert_capacity']} experts · {names}") else: lines.append("VRAM no NVIDIA device detected · CPU path") + lines.append(f"limit {plan['expected_bottleneck']}") lines.extend(f"warn {warning}" for warning in plan["warnings"]) return "\n".join(lines) diff --git a/c/tests/bench_tensor_core.cu b/c/tests/bench_tensor_core.cu new file mode 100644 index 0000000..c8c4362 --- /dev/null +++ b/c/tests/bench_tensor_core.cu @@ -0,0 +1,45 @@ +#include "../backend_cuda.h" + +#include +#include +#include +#include +#include + +static double run(ColiCudaTensor *g,ColiCudaTensor *u,ColiCudaTensor *d, + const float *x,float *y,int rows,int iterations,int mode){ + ColiCudaTensor *gs[1]={g},*us[1]={u},*ds[1]={d}; int rs[1]={rows}; + if(mode==2){setenv("COLI_CUDA_TC_INT4","1",1);setenv("COLI_CUDA_TC_MIN_ROWS","1",1);} + else unsetenv("COLI_CUDA_TC_INT4"); + setenv("COLI_CUDA_W4_PACKED",mode==0?"0":"1",1); + if(!coli_cuda_expert_group(gs,us,ds,rs,1,y,x))std::exit(2); + auto begin=std::chrono::steady_clock::now(); + for(int i=0;i(end-begin).count()/iterations; +} + +int main(){ + constexpr int D=6144,I=2048,O=8; + int device=0;if(!coli_cuda_init(&device,1))return 77; + std::vector hidden((size_t)I*D/2),down((size_t)D*I/2); + std::vector hs(I),ds(D),x((size_t)O*D),a((size_t)O*D),b((size_t)O*D),c((size_t)O*D); + for(size_t i=0;ilimit){std::fprintf(stderr,"relative RMS %.5f exceeds %.5f\n",r,limit);return 0;} return 1; +} + int main(int argc, char **argv) { int devices[COLI_CUDA_MAX_DEVICES], ndev = argc > 1 ? argc - 1 : 1; if (ndev > COLI_CUDA_MAX_DEVICES) return 2; @@ -55,8 +61,67 @@ int main(int argc, char **argv) { ColiCudaTensor *tf = nullptr; if (!coli_cuda_matmul(&tf, got, x, wf, nullptr, 0, 1, 4, 2, d0) || !close_enough(got, wantf, 2)) return 1; + const float eg[8] = {1,0,0,0, 0,1,0,0}; + const float eu[8] = {1,0,0,0, 0,1,0,0}; + const float ed[8] = {1,0, 0,1, 1,1, 1,-1}; + ColiCudaTensor *tg=nullptr,*tu=nullptr,*td=nullptr; + if (!coli_cuda_tensor_upload(&tg,eg,nullptr,0,4,2,d0) || + !coli_cuda_tensor_upload(&tu,eu,nullptr,0,4,2,d0) || + !coli_cuda_tensor_upload(&td,ed,nullptr,0,2,4,d0)) return 1; + float expert[8], want_expert[8]; + for(int s=0;s<2;s++){ + float a=x[s*4], b=x[s*4+1]; + a=(a/(1.0f+std::exp(-a)))*a; b=(b/(1.0f+std::exp(-b)))*b; + want_expert[s*4]=a; want_expert[s*4+1]=b; + want_expert[s*4+2]=a+b; want_expert[s*4+3]=a-b; + } + if (!coli_cuda_expert_mlp(tg,tu,td,expert,x,2) || + !close_enough(expert,want_expert,8)) return 1; + ColiCudaTensor *gates[2]={tg,tg},*ups[2]={tu,tu},*downs[2]={td,td}; + int group_rows[2]={1,1}; float grouped[8]; + if (!coli_cuda_expert_group(gates,ups,downs,group_rows,2,grouped,x) || + !close_enough(grouped,want_expert,8)) return 1; + + const float aw[16]={1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1}; + const float aq[4]={1,2,.5f,-.5f},al[12]={1,0,0,0, 0,1,0,0, 0,0,1,0}; + const float ar[6]={1,0, 0,1, 1,1};float actx[2],aref[2]; + ColiCudaTensor *at=nullptr;if(!coli_cuda_tensor_upload(&at,aw,nullptr,0,4,4,d0))return 1; + float score[3];for(int t=0;t<3;t++)score[t]=aq[0]*al[t*4]+aq[1]*al[t*4+1]+aq[2]*ar[t*2]+aq[3]*ar[t*2+1]; + float mx=score[0],z=0;for(int t=1;t<3;t++)mx=score[t]>mx?score[t]:mx; + for(int t=0;t<3;t++){score[t]=std::exp(score[t]-mx);z+=score[t];}for(int t=0;t<3;t++)score[t]/=z; + for(int v=0;v<2;v++){aref[v]=0;for(int t=0;t<3;t++)aref[v]+=score[t]*al[t*4+2+v];} + if(!coli_cuda_attention_absorb(at,actx,aq,al,ar,1,2,2,2,4,3,1.f)|| + !close_enough(actx,aref,2))return 1; + coli_cuda_tensor_free(at); + + /* Native s4 WMMA path: compare the quantized-activation result against the + existing FP32-activation/s4-weight grouped implementation. */ + uint8_t w4[32*32/2]; float ws4[32], gx4[64], scalar4[64], tensor4[64]; + for(int i=0;i<(int)sizeof(w4);i++){ + int lo=((i%15)-7)&15,hi=(((i*3)%15)-7)&15; + w4[i]=(uint8_t)(lo|(hi<<4)); + } + for(int i=0;i<32;i++)ws4[i]=0.01f+(i%5)*0.002f; + for(int i=0;i<64;i++)gx4[i]=std::sin((float)(i+1)*0.17f)*2.f; + ColiCudaTensor *g4=nullptr,*u4=nullptr,*d4=nullptr; + if(!coli_cuda_tensor_upload(&g4,w4,ws4,2,32,32,d0)|| + !coli_cuda_tensor_upload(&u4,w4,ws4,2,32,32,d0)|| + !coli_cuda_tensor_upload(&d4,w4,ws4,2,32,32,d0))return 1; + ColiCudaTensor *gg4[2]={g4,g4},*ug4[2]={u4,u4},*dg4[2]={d4,d4}; + if(!coli_cuda_expert_group(gg4,ug4,dg4,group_rows,2,scalar4,gx4))return 1; + setenv("COLI_CUDA_TC_INT4","1",1); + setenv("COLI_CUDA_TC_MIN_ROWS","1",1); + if(!coli_cuda_expert_group(gg4,ug4,dg4,group_rows,2,tensor4,gx4)|| + !relative_rms(tensor4,scalar4,64,0.30f))return 1; + unsetenv("COLI_CUDA_TC_INT4"); + unsetenv("COLI_CUDA_TC_MIN_ROWS"); + coli_cuda_tensor_free(g4);coli_cuda_tensor_free(u4);coli_cuda_tensor_free(d4); + uint64_t group_calls=0,group_experts=0,group_total_rows=0; + coli_cuda_group_stats(&group_calls,&group_experts,&group_total_rows,nullptr,nullptr,nullptr); + if(group_calls!=3||group_experts!=6||group_total_rows!=6) return 1; + coli_cuda_stats(-1, &count, &bytes); - if (count != 4 || bytes != 70) { + if (count != 7 || bytes != 166) { std::fprintf(stderr, "unexpected CUDA stats: %zu tensors, %zu bytes\n", count, bytes); return 1; } @@ -64,15 +129,18 @@ int main(int argc, char **argv) { coli_cuda_tensor_device(t4) != d1 || coli_cuda_tensor_device(t2) != d1) return 1; coli_cuda_stats(d0, &count, &bytes); if (ndev > 1) { - if (count != 2 || bytes != 48) return 1; + if (count != 5 || bytes != 144) return 1; coli_cuda_stats(d1, &count, &bytes); if (count != 2 || bytes != 22) return 1; - } else if (count != 4 || bytes != 70) return 1; + } else if (count != 7 || bytes != 166) return 1; coli_cuda_tensor_free(t8); coli_cuda_tensor_free(t4); coli_cuda_tensor_free(t2); coli_cuda_tensor_free(tf); + coli_cuda_tensor_free(tg); + coli_cuda_tensor_free(tu); + coli_cuda_tensor_free(td); coli_cuda_stats(-1, &count, &bytes); if (count || bytes) return 1; coli_cuda_shutdown(); diff --git a/c/tests/test_decode_batch b/c/tests/test_decode_batch new file mode 100755 index 0000000000000000000000000000000000000000..989fe4235faf6f6123f794df737ebcb19165a265 GIT binary patch literal 16448 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 literal 0 HcmV?d00001 diff --git a/c/tests/test_decode_batch.c b/c/tests/test_decode_batch.c new file mode 100644 index 0000000..d370d2d --- /dev/null +++ b/c/tests/test_decode_batch.c @@ -0,0 +1,56 @@ +#include +#include + +#include "../decode_batch.h" + +static void test_rows_use_their_own_sequence_storage(void) +{ + float sequence_a[4 * 3] = {0}; + float sequence_b[4 * 3] = {0}; + + float *a2 = coli_kv_row(sequence_a, 2, 3); + float *b1 = coli_kv_row(sequence_b, 1, 3); + a2[0] = 20.0f; + b1[2] = 12.0f; + + assert(a2 == &sequence_a[6]); + assert(b1 == &sequence_b[3]); + assert(sequence_a[6] == 20.0f); + assert(sequence_b[5] == 12.0f); + assert(sequence_a[5] == 0.0f); + assert(sequence_b[6] == 0.0f); +} + +static void test_const_reader_selects_the_same_row(void) +{ + float storage[5 * 7] = {0}; + const float *row = coli_kv_row(storage, 4, 7); + + assert(row == &storage[28]); +} + +static void test_submit_header(void) +{ + ColiSubmit sub; + assert(coli_submit_parse("SUBMIT 42 3 17 64 0.7 0.95", &sub)); + assert(sub.id == 42 && sub.slot == 3 && sub.bytes == 17); + assert(sub.max_tokens == 64 && sub.temperature > .69f && sub.top_p > .94f); + assert(!coli_submit_parse("SUBMIT 1 -1 2 3 0.7 1", &sub)); + assert(!coli_submit_parse("SUBMIT 1 0 2 0 0.7 1", &sub)); + assert(!coli_submit_parse("SUBMIT 1 0 2 3 4 1", &sub)); + assert(!coli_submit_parse("SUBMIT 0 0 2 3 1 1", &sub)); + assert(!coli_submit_parse("SUBMIT 1 0 2 3 nan 1", &sub)); + assert(!coli_submit_parse("SUBMIT 1 0 2 3 1 inf", &sub)); + assert(coli_submit_parse("SUBMIT 1 0 16777216 3 1 1", &sub)); + assert(!coli_submit_parse("SUBMIT 1 0 16777217 3 1 1", &sub)); + assert(!coli_submit_parse("SUBMIT 1 0 2 3 1 1 trailing", &sub)); +} + +int main(void) +{ + test_rows_use_their_own_sequence_storage(); + test_const_reader_selects_the_same_row(); + test_submit_header(); + puts("decode batch helper tests: ok"); + return 0; +} diff --git a/c/tests/test_doctor.py b/c/tests/test_doctor.py index c618636..1647a0d 100644 --- a/c/tests/test_doctor.py +++ b/c/tests/test_doctor.py @@ -142,7 +142,7 @@ class DoctorTest(unittest.TestCase): output = format_doctor(self.report()) self.assertIn("model.path", output) - self.assertIn("disk backing store", output) + self.assertIn("disk 0.0 GB cold experts", output) self.assertTrue(output.endswith("result ok")) def test_cli_json_is_machine_readable_without_loading_model(self): diff --git a/c/tests/test_i4_acc512 b/c/tests/test_i4_acc512 new file mode 100755 index 0000000000000000000000000000000000000000..76f68a3e70a9375bd3b0c6f710f7af09dd687e68 GIT binary patch literal 16200 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 RX36xm8I?ZlN#MCO{uAA7+cW?G literal 0 HcmV?d00001 diff --git a/c/tests/test_openai_server.py b/c/tests/test_openai_server.py index 06d3218..9792403 100644 --- a/c/tests/test_openai_server.py +++ b/c/tests/test_openai_server.py @@ -1,19 +1,24 @@ import io import json +import math +import socket import threading import unittest +from unittest.mock import patch from urllib.error import HTTPError from urllib.request import Request, urlopen from openai_server import (APIError, APIServer, ClientCancelled, END, GenerationScheduler, - generation_options, read_engine_turn, render_chat, serve) + READY, Engine, generation_options, read_engine_turn, render_chat, + serve) class FakeEngine: def __init__(self): self.calls = [] - def generate(self, prompt, maximum, temperature, top_p, on_text, cache_slot=0): + def generate(self, prompt, maximum, temperature, top_p, on_text, cache_slot=0, + cancelled=None): self.calls.append((prompt, maximum, temperature, top_p, cache_slot)) on_text("Hé") on_text("llo") @@ -26,10 +31,12 @@ class BlockingEngine(FakeEngine): self.entered = threading.Event() self.release = threading.Event() - def generate(self, prompt, maximum, temperature, top_p, on_text, cache_slot=0): + def generate(self, prompt, maximum, temperature, top_p, on_text, cache_slot=0, + cancelled=None): self.entered.set() self.release.wait(2) - return super().generate(prompt, maximum, temperature, top_p, on_text, cache_slot) + return super().generate(prompt, maximum, temperature, top_p, on_text, cache_slot, + cancelled) class TemplateTest(unittest.TestCase): @@ -65,6 +72,10 @@ class TemplateTest(unittest.TestCase): (4, 0.0, 1.0)) with self.assertRaises(APIError): generation_options({"max_tokens": 9}, 8) + with self.assertRaises(APIError): + generation_options({"temperature": math.nan}, 8) + with self.assertRaises(APIError): + generation_options({"top_p": math.inf}, 8) self.assertEqual(generation_options({"temperature": None, "top_p": None}, 8), (8, 0.7, 0.9)) @@ -82,8 +93,27 @@ class ProtocolTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "kv_slots"): serve("/missing", kv_slots=0) + def test_occupied_port_fails_before_engine_start(self): + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen() + try: + with patch("openai_server.subprocess.Popen") as popen: + with self.assertRaises(OSError): + serve("/missing", port=listener.getsockname()[1]) + popen.assert_not_called() + finally: + listener.close() + class SchedulerTest(unittest.TestCase): + def test_admits_up_to_capacity_without_serializing(self): + scheduler = GenerationScheduler(max_queue=0, queue_timeout=1, capacity=2) + with scheduler.admit() as first: + with scheduler.admit() as second: + self.assertEqual({first[1], second[1]}, {0, 1}) + self.assertEqual(scheduler.snapshot()["active"], 2) + def test_rejects_when_waiting_queue_is_full(self): scheduler = GenerationScheduler(max_queue=0, queue_timeout=1) with scheduler.admit(): @@ -160,6 +190,213 @@ class SchedulerTest(unittest.TestCase): self.assertEqual(errors, ["scheduler_closed"]) +class BlockingStream: + def __init__(self, initial=b""): + self.buffer = bytearray(initial) + self.closed = False + self.condition = threading.Condition() + + def feed(self, data): + with self.condition: + self.buffer.extend(data) + self.condition.notify_all() + + def read(self, size=1): + with self.condition: + while len(self.buffer) < size and not self.closed: + self.condition.wait() + if not self.buffer and self.closed: + return b"" + size = min(size, len(self.buffer)) + data = bytes(self.buffer[:size]) + del self.buffer[:size] + return data + + def readline(self): + with self.condition: + while b"\n" not in self.buffer and not self.closed: + self.condition.wait() + if not self.buffer and self.closed: + return b"" + end = self.buffer.find(b"\n") + size = len(self.buffer) if end < 0 else end + 1 + data = bytes(self.buffer[:size]) + del self.buffer[:size] + return data + + def close(self): + with self.condition: + self.closed = True + self.condition.notify_all() + + +class FakeProcess: + def __init__(self, on_write): + self.stdout = BlockingStream(READY + b"STAT 0 0 0 0\n") + self.stdin = self + self.on_write = on_write + self.writes = [] + self.returncode = None + + def write(self, data): + self.writes.append(data) + self.on_write(self, data) + return len(data) + + def flush(self): + pass + + def poll(self): + return self.returncode + + def terminate(self): + self.returncode = 0 + self.stdout.close() + + def wait(self, timeout=None): + return self.returncode + + def kill(self): + self.terminate() + + +class DispatcherTest(unittest.TestCase): + def test_dispatches_interleaved_requests_by_id(self): + submitted = [] + + def respond(process, frame): + fields = frame.split(b"\n", 1)[0].split() + self.assertEqual(fields[0], b"SUBMIT") + submitted.append(fields[1]) + if len(submitted) == 2: + first, second = submitted + process.stdout.feed(b"DATA " + second + b" 3\nB-2\n") + process.stdout.feed(b"DATA " + first + b" 3\nA-1\n") + process.stdout.feed(b"DONE " + second + b" STAT 1 2.5 0 1.0 4 0\n") + process.stdout.feed(b"DATA " + first + b" 3\nA-2\n") + process.stdout.feed(b"DONE " + first + b" STAT 2 3.5 0 1.0 5 1\n") + + process = FakeProcess(respond) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model", kv_slots=2) + results = {} + + def generate(name, prompt, slot): + chunks = [] + stats = engine.generate(prompt, 8, 0.7, 0.9, chunks.append, slot) + results[name] = ("".join(chunks), stats) + + threads = [threading.Thread(target=generate, args=("a", "alpha", 0)), + threading.Thread(target=generate, args=("b", "beta", 1))] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=2) + self.assertFalse(thread.is_alive()) + engine.close() + + self.assertEqual(results["a"][0], "A-1A-2") + self.assertTrue(results["a"][1]["length_limited"]) + self.assertEqual(results["b"][0], "B-2") + headers = [frame.split(b"\n", 1)[0].split() for frame in process.writes] + self.assertEqual({int(header[2]) for header in headers}, {0, 1}) + self.assertEqual({header[3] for header in headers}, {b"4", b"5"}) + + def test_routes_engine_error_to_request(self): + def respond(process, frame): + request_id = frame.split()[1] + process.stdout.feed(b"ERROR " + request_id + b" slot is busy\n") + + process = FakeProcess(respond) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model") + with self.assertRaisesRegex(RuntimeError, "slot is busy"): + engine.generate("hello", 4, 0.7, 0.9, lambda _: None) + engine.close() + + def test_close_wakes_pending_generation_and_is_idempotent(self): + process = FakeProcess(lambda _process, _frame: None) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model") + errors = [] + + def generate(): + try: + engine.generate("hello", 4, 0.7, 0.9, lambda _: None) + except RuntimeError as error: + errors.append(str(error)) + + thread = threading.Thread(target=generate) + thread.start() + for _ in range(100): + with engine.pending_lock: + if engine.pending: + break + threading.Event().wait(0.01) + engine.close() + engine.close() + thread.join(timeout=2) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, ["colibri engine is shutting down"]) + self.assertFalse(engine.dispatcher.is_alive()) + with engine.pending_lock: + self.assertFalse(engine.pending) + with self.assertRaisesRegex(RuntimeError, "shutting down"): + engine.generate("again", 4, 0.7, 0.9, lambda _: None) + + def test_protocol_corruption_fails_request_and_stops_dispatcher(self): + def respond(process, frame): + request_id = frame.split()[1] + process.stdout.feed(b"DATA " + request_id + b" -1\n") + + process = FakeProcess(respond) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model") + with self.assertRaisesRegex(RuntimeError, "DATA size"): + engine.generate("hello", 4, 0.7, 0.9, lambda _: None) + with self.assertRaisesRegex(RuntimeError, "dispatcher stopped"): + engine.generate("again", 4, 0.7, 0.9, lambda _: None) + engine.close() + + def test_decodes_utf8_split_across_data_frames(self): + def respond(process, frame): + request_id = frame.split()[1] + process.stdout.feed(b"DATA " + request_id + b" 1\n\xc3\n") + process.stdout.feed(b"DATA " + request_id + b" 1\n\xa9\n") + process.stdout.feed(b"DONE " + request_id + b" STAT 1 1 0 1 1 0\n") + + process = FakeProcess(respond) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model") + chunks = [] + engine.generate("hello", 4, 0.7, 0.9, chunks.append) + engine.close() + self.assertEqual(chunks, ["é"]) + + def test_cancels_generation_after_consumer_disconnects(self): + request_id = None + + def respond(process, frame): + nonlocal request_id + fields = frame.split() + if fields[0] == b"SUBMIT": + request_id = fields[1] + process.stdout.feed(b"DATA " + request_id + b" 1\nx\n") + elif fields[0] == b"CANCEL": + self.assertEqual(fields[1], request_id) + process.stdout.feed(b"ERROR " + request_id + b" CANCELLED\n") + + process = FakeProcess(respond) + with patch("openai_server.subprocess.Popen", return_value=process): + engine = Engine("glm", "model") + output = [] + with self.assertRaises(ClientCancelled): + engine.generate("hello", 8, 0.7, 0.9, output.append, cancelled=lambda: True) + engine.close() + self.assertEqual(output, ["x"]) + self.assertEqual(process.writes[-1].split(), [b"CANCEL", request_id]) + + class HTTPTest(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/c/tests/test_resource_plan.py b/c/tests/test_resource_plan.py index 3b965fe..d44f661 100644 --- a/c/tests/test_resource_plan.py +++ b/c/tests/test_resource_plan.py @@ -57,11 +57,16 @@ class ResourcePlanTest(unittest.TestCase): gpus = [{"index": 0, "name": "test-gpu", "total_bytes": 12 * GB, "free_bytes": 10 * GB}] plan = build_plan(self.model, ram_gb=16, context=32, vram_gb=20, - available_memory=32 * GB, available_disk=100 * GB, gpus=gpus) - self.assertEqual(plan["version"], 1) + available_memory=32 * GB, available_disk=100 * GB, gpus=gpus, + physical_cpus=24) + self.assertEqual(plan["version"], 2) + self.assertEqual(plan["policy"]["name"], "quality") + self.assertEqual(plan["cpu"]["physical_cores"], 24) + self.assertTrue(plan["policy"]["preserve_quantization"]) + self.assertFalse(plan["tiers"]["vram"]["requires_host_backing"]) self.assertEqual(plan["tiers"]["ram"]["budget_bytes"], 16 * GB) self.assertLessEqual(plan["tiers"]["vram"]["budget_bytes"], 8 * GB) - self.assertIn("required RAM backing", plan["warnings"][0]) + self.assertIn("clamped", plan["warnings"][0]) self.assertIn("0:test-gpu", format_plan(plan)) def test_filters_requested_devices(self): @@ -78,7 +83,7 @@ class ResourcePlanTest(unittest.TestCase): "--gpu", "none", "--json", ], text=True, capture_output=True, check=True) plan = json.loads(run.stdout) - self.assertEqual(plan["version"], 1) + self.assertEqual(plan["version"], 2) self.assertEqual(plan["model"]["expert_count"], 2) def test_applies_plan_without_overriding_explicit_settings(self): @@ -93,8 +98,15 @@ class ResourcePlanTest(unittest.TestCase): self.assertEqual(env["RAM_GB"], "12") self.assertEqual(env["COLI_CUDA"], "1") self.assertEqual(env["COLI_GPUS"], "1") + self.assertEqual(env["OMP_NUM_THREADS"], str(plan["cpu"]["physical_cores"])) + self.assertEqual(env["OMP_PROC_BIND"], "spread") + self.assertEqual(env["OMP_PLACES"], "cores") self.assertEqual(env["PIN_GB"], env["CUDA_EXPERT_GB"]) + explicit_threads = environment_for_plan(plan, {"OMP_NUM_THREADS": "7", + "OMP_PROC_BIND": "close"}) + self.assertEqual(explicit_threads["OMP_NUM_THREADS"], "7") + self.assertEqual(explicit_threads["OMP_PROC_BIND"], "close") def test_cpu_binary_does_not_apply_gpu_tier(self): plan = build_plan(self.model, available_memory=16 * GB, available_disk=1, gpus=[{"index": 0, "name": "a", "total_bytes": 8 * GB, @@ -106,6 +118,32 @@ class ResourcePlanTest(unittest.TestCase): self.assertNotIn("COLI_GPU", disabled) self.assertNotIn("CUDA_EXPERT_GB", disabled) + def test_rejects_unknown_policy_and_marks_experimental_policy(self): + with self.assertRaisesRegex(ValueError, "unknown policy"): + build_plan(self.model, available_memory=16 * GB, available_disk=1, + gpus=[], policy="fast-ish") + plan = build_plan(self.model, available_memory=16 * GB, available_disk=1, + gpus=[], policy="experimental-fast") + self.assertFalse(plan["policy"]["quality_preserving"]) + self.assertFalse(plan["policy"]["preserve_router"]) + + def test_balanced_policy_enables_lossless_live_repin(self): + plan = build_plan(self.model, available_memory=16 * GB, available_disk=1, + gpus=[], policy="balanced") + env = environment_for_plan(plan) + self.assertEqual(env["COLI_POLICY"], "balanced") + self.assertEqual(env["REPIN"], "64") + explicit = environment_for_plan(plan, {"REPIN": "0"}) + self.assertEqual(explicit["REPIN"], "0") + + def test_plan_explains_hot_warm_and_cold_placement(self): + plan = build_plan(self.model, ram_gb=4, vram_gb=0, + available_memory=4 * GB, available_disk=1, gpus=[]) + self.assertEqual([item["target"] for item in plan["decisions"]], + ["VRAM", "RAM", "Disk"]) + self.assertIn("quality-preserving yes", format_plan(plan)) + self.assertIn("expected_bottleneck", plan) + if __name__ == "__main__": unittest.main() diff --git a/c/tests/test_tier.c b/c/tests/test_tier.c index 9b68981..eb6f1ac 100644 --- a/c/tests/test_tier.c +++ b/c/tests/test_tier.c @@ -17,6 +17,11 @@ int main(void){ tier_decay(heat,6); if(heat[0]!=10 || heat[1]!=1 || heat[4]!=15) return fail("heat decay"); + + uint32_t freq[5]={10,10,2,18,18}, last[5]={10,90,95,20,99}; + int live[2]={0,1}; + if(!tier_pick_lfru(freq,last,100,5,live,2,&slot,&eid,&gain)) return fail("LFRU promotion"); + if(slot!=0||eid!=4) return fail("LFRU did not prefer recent ties"); puts("tier tests: ok"); return 0; } diff --git a/c/tier.h b/c/tier.h index 8f66cdd..859cacf 100644 --- a/c/tier.h +++ b/c/tier.h @@ -24,6 +24,35 @@ static int tier_pick_swap(const uint32_t *heat, int nexpert, return 1; } +/* LFRU: frequency is the primary signal; recency breaks close calls. A recent + * access contributes at most 255 points while one frequency count is worth + * 256, so a merely recent expert cannot displace a genuinely hotter one. */ +static uint64_t tier_lfru_score(uint32_t heat, uint32_t last, uint32_t clock){ + uint32_t age=clock-last, recent=age<255?255-age:0; + return ((uint64_t)heat<<8)|recent; +} + +static int tier_pick_lfru(const uint32_t *heat, const uint32_t *last, uint32_t clock, + int nexpert, const int *pinned, int npin, + int *slot, int *eid, long *gain){ + if(!heat||!last||!pinned||npin<1||nexpert<1) return 0; + int cold=0; + for(int z=1;zhs)){ hot=e; hs=score; } + } + if(hot<0) return 0; + uint64_t cs=tier_lfru_score(heat[pinned[cold]],last[pinned[cold]],clock); + /* Retain the existing 25%+4-frequency hysteresis in score units. */ + if(hs<=cs+(cs>>2)+(4u<<8)) return 0; + *slot=cold; *eid=hot; *gain=(long)((hs-cs)>>8); return 1; +} + static void tier_decay(uint32_t *heat, int nexpert){ for(int e=0;e>=1; }