Files
colibri/c/tools/expert_atlas.py
T
ZacharyZcR ec89136029 GPU resident pipeline: batch CUDA attention, head-sharded kv_b, prefill expert groups, W4A16 mixed dispatch (#111)
* 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

* Add full-resident expert placement mode

* Adapt VRAM expert slots to live routes

* Accelerate int4 matvec on AVX-512

* Reduce AVX-512 and RoPE decode overhead

* Seed every GPU expert layer after prefill

* Limit live GPU swaps during decode

* CUDA batch MLA attention, kv_b head-sharding, fused o_proj, expert-group dispatch, W4A16 kernels

Lab-qualified on the 6x RTX 5090 machine (914-token request benchmark):
- batch MLA absorption kernel (COLI_CUDA_ATTN=1): whole-batch attention on
  device, 154.8s -> 102.4s
- attention -> o_proj fusion on the layer device: -> 97.4s
- kv_b head-sharding across cards (COLI_CUDA_ATTN_SHARD=1), no weight
  duplication: -> 94.05s
- per-device expert-group dispatch with pinned-buffer async transfers,
  W4A16 tensor-core kernels for the shared expert, OMP hot-thread tuning

Negative results (reverted, kept out): GPU-side weighted scatter-add
(atomics + per-layer D2H lose 43.8%), shared-expert fused small-batch
kernel (-38.8%), W4A4 grouped tensor cores (int4 activations corrupt
output). Details in the lab research log.

* GPU resident pipeline: device-resident prefill attention chain, GPU expert groups in prefill, batched router, W4A16 mixed dispatch

COLI_CUDA_PIPE=1 keeps the prefill data plane on the layer home device;
control flow (routing, cache/pin management) stays on CPU. Any CUDA
failure falls back to the unchanged CPU path.

- Device primitives + unit tests (tests/test_pipe_cuda.cu): rmsnorm
  (strided), interleaved RoPE, silu-mul, residual add, fixed-order row
  merge (no atomics), device-input GEMM, persistent per-device scratch.
  All verified against the engine's CPU math on SM120 (worst 1.2e-5).
- attn_pipe_prefill: q_a -> norm -> q_b -> rope -> kv_a -> norm -> rope ->
  batch attention -> o_proj in one device chain (q_a/q_b/kv_a colocated
  with kv_b); only the final [S,D] and the new KV rows return to host.
  Attention 41.2s -> 30.8s on the 1571-token benchmark.
- Prefill batch-union now uses the GPU expert groups (previously gated to
  S<=64, leaving all VRAM-resident experts idle during prefill - measured
  21ms of GPU expert time in a 148s prefill). Expert phase 78.9s -> 69.0s.
- Router computed as one batched matmul instead of S sequential rows
  (bit-identical math).
- W4A16 tensor-core path for expert groups (COLI_CUDA_TC_W4A16=1) with
  row-count mixed dispatch: >=16 rows per expert use tensor cores, smaller
  batches keep the naive kernel (tensor cores measured negative below
  ~16 rows). Expert phase 69.0s -> 64.3s, decode unaffected.

Net on the 1571-token prefill benchmark: 148.8s -> 114.3-126.8s
(component timings stable across runs; wall drifts +-3-5s because
.coli_usage placement learning shifts the expert tiers between runs).
PROFILO now also prints the prefill-phase breakdown.

* Skip OMP hot-thread tuning when CUDA is enabled

The active-spin worker team measured 66.9s->20.9s on the CPU-only Zen5
build, but on the six-GPU full-residency workload the spinning workers
contend with the CUDA dispatch threads: ~4x slower prefill with the
process stuck near 1.8 cores. Gate the tuning on COLI_CUDA so each
configuration keeps the behavior it was measured to prefer.

* Inc.2a: sparse layers fully resident on the layer device, residual hops cards at layer boundaries

COLI_CUDA_PIPE=2 keeps the residual stream on the layer home device for
consecutive sparse layers (cudaMemcpyPeer at boundaries): in/post norms,
attention chain, both residual adds and the shared-expert MLP run on
device. Per layer only the post-norm activations (router + CPU-tier
experts + group gather), the new KV rows and, on DSA indexer layers, the
pre-attention norm leave the card. Per-layer transfers drop from ~130MB
to ~70MB. A device-side snapshot at layer entry makes any mid-layer CUDA
failure fall back to the unchanged CPU path idempotently.

1571-token prefill: 127.1s (PIPE=1 control) -> 117.6/118.9s, components
attention 30.8->26.1, other 31.8->22.5-24.5; output verified coherent
against the control.

* Head-sharded attention inside the pipe: negative on PCIe star topology, gated opt-in

Slicing q per card from the home device and collecting ctx back
serializes ~95MB/layer through the home card's PCIe link: attention
26.1s -> 41.4/44.4s on the 1571-token benchmark (two repeats), wall
117.6 -> 135-138s. The standalone host-path sharding won because six
cards uploaded from host RAM in parallel; a home-device star has no
such parallelism without NVLink. Kept behind COLI_CUDA_PIPE_SHARD=1
for interconnects where peer bandwidth does not share one root port.

* Inc.3: device-resident KV shadow for decode attention

Decode re-uploaded the whole latent+rope window per layer per token
(~300MB/token at 1571 context). Each layer now keeps a device shadow of
the compressed KV on its kv_b card, bulk-synced when behind and appended
incrementally; the host cache stays canonical. Invalidation on kv_bind
(slot switch), kv_alloc (resize) and on any overwrite of mirrored rows,
with the legacy full-upload path as fallback.

Measured (COLI_CUDA_PIPE gate): short-context decode 5.48 -> 5.59/5.87
tok/s, 1571-context decode 4.14 -> 4.22 tok/s. Decode remains CPU-expert
bound; the shadow removes the transfer tax, not the compute.

* tools: unified user-experience benchmark (bench_ux.sh)

Two fixed scenarios (short chat, long-document QA), TTFT + decode tok/s
+ first-line drift check, TEMP=0 DRAFT=0 enforced, medians over REPS
runs. Encodes the measurement discipline from the lab record: same
binary per comparison, judge medians because .coli_usage placement
learning drifts wall times between runs.

* tools: bench_ux.sh executable bit

* gitignore compiled test binaries

* tools: expert_atlas.py — measure per-expert topic affinity (#175)

Diffs .coli_usage across 10 themed probe batches (code/math/chinese/
prose/science/law/poetry/structured/translation/casual, 3 prompts each)
driven through a running API server — one engine load total. Every
touched expert gets a topic-affinity vector, entropy, and a specialist/
generalist label; output experts.json feeds the Brain page hover.

* serve: persist .coli_usage after every turn in mux mode, not only at exit

run_serve_mux saved the learning cache once at shutdown; a crash lost
the whole session's routing history, and live consumers of the file
(expert_atlas.py diffs it between probe batches) saw a frozen snapshot.
Now saved per turn like the interactive path (165KB write, negligible).

* web: Brain hover shows measured expert atlas when published

If /experts.json (from tools/expert_atlas.py, #175) is served next to
the app, the tooltip upgrades from the depth heuristic to measured
data: specialist/generalist label, entropy, and the top-3 topic
affinities. Row index maps to real layer (row+3, last row = MTP 78).
Falls back to the heuristic when no atlas is published.

---------

Co-authored-by: JustVugg <JustVugg@users.noreply.github.com>
2026-07-14 18:18:05 +02:00

142 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Expert Atlas (#175): measure per-expert topic affinity by diffing .coli_usage
across themed probe batches, served through a running colibri API server.
Protocol per category: snapshot .coli_usage -> send probes -> snapshot again;
the delta is that category's expert-activation spectrum. One engine load total.
Output: experts.json — for every (layer, expert): counts per category,
normalized affinity, entropy, and a "specialist" label when one topic dominates.
Usage (server already running with the model):
python3 tools/expert_atlas.py --api http://127.0.0.1:8000 \
--usage /path/to/model/.coli_usage --out experts.json --ngen 64
"""
import argparse, json, math, time, urllib.request
PROBES = {
"code": [
"Write a Python function that parses a CSV file and returns a dict keyed by the first column.",
"Explain the difference between a mutex and a semaphore, with a C example.",
"Refactor this into idiomatic Rust: for i in range(len(xs)): total += xs[i] * 2",
],
"math": [
"Prove that the square root of 2 is irrational.",
"Compute the derivative of x^3 * ln(x) and explain each step.",
"A fair die is rolled 4 times. What is the probability of at least one six?",
],
"chinese": [
"请用中文解释一下什么是光合作用,以及它对地球生态系统的重要性。",
"把这句话翻译成中文并解释语法:The early bird catches the worm.",
"写一段关于秋天的短文,一百字左右。",
],
"english_prose": [
"Write a vivid paragraph describing an old lighthouse keeper watching a storm arrive.",
"Summarize the plot of Romeo and Juliet in three sentences.",
"Continue this story: The last train left the station, and Maria realized her mistake.",
],
"science": [
"Explain how mRNA vaccines work at the cellular level.",
"Why is the sky blue during the day but red at sunset?",
"Describe the life cycle of a massive star, from formation to supernova.",
],
"law": [
"Explain the difference between a patent, a trademark, and a copyright.",
"What are the key elements required to form a legally binding contract?",
"Summarize what 'due process' means in constitutional law.",
],
"poetry": [
"Write a short poem about a hummingbird in the style of Emily Dickinson.",
"Compose a haiku about winter rain, then explain its imagery.",
"Write four rhyming lines about the sea at night.",
],
"structured": [
'Convert to JSON: name Alice, age 30, hobbies reading and chess, address 5 Oak St.',
"Write a SQL query returning the top 5 customers by total order value, with the schema you assume.",
"Write a regex that matches ISO-8601 dates and explain each part.",
],
"translation": [
"Translate into French, German and Spanish: 'Knowledge is the only treasure that grows when shared.'",
"Translate this Italian sentence to English and comment on nuance: 'In bocca al lupo per domani.'",
"Translate into Japanese: 'The meeting has been moved to next Tuesday afternoon.'",
],
"casual": [
"Hey! Any tips for staying awake during boring afternoon meetings?",
"What should I cook tonight? I have eggs, rice, tomatoes and some cheese.",
"My friend is always late. How do I tell them it bothers me without being rude?",
],
}
def read_usage(path):
counts = {}
try:
with open(path) as f:
for line in f:
p = line.split()
if len(p) == 3:
counts[(int(p[0]), int(p[1]))] = int(p[2])
except FileNotFoundError:
pass
return counts
def diff(after, before):
return {k: v - before.get(k, 0) for k, v in after.items() if v - before.get(k, 0) > 0}
def chat(api, prompt, ngen):
body = json.dumps({"model": "glm-5.2-colibri", "stream": False, "max_tokens": ngen,
"messages": [{"role": "user", "content": prompt}]}).encode()
req = urllib.request.Request(f"{api}/v1/chat/completions", data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=600) as r:
json.load(r)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--api", default="http://127.0.0.1:8000")
ap.add_argument("--usage", required=True, help="path to the model's .coli_usage")
ap.add_argument("--out", default="experts.json")
ap.add_argument("--ngen", type=int, default=64)
a = ap.parse_args()
spectra = {}
for cat, prompts in PROBES.items():
before = read_usage(a.usage)
t0 = time.time()
for p in prompts:
chat(a.api, p, a.ngen)
time.sleep(2) # let the engine flush .coli_usage
spectra[cat] = diff(read_usage(a.usage), before)
total = sum(spectra[cat].values())
print(f"[{cat}] {len(spectra[cat])} experts touched, {total} selections, {time.time()-t0:.0f}s", flush=True)
cats = list(PROBES.keys())
experts = {}
for cat, spec in spectra.items():
for k, v in spec.items():
experts.setdefault(k, {c: 0 for c in cats})[cat] = v
atlas = {}
for (layer, eid), counts in experts.items():
total = sum(counts.values())
if total < 8:
continue # too few observations to characterise
aff = {c: v / total for c, v in counts.items() if v}
ent = -sum(p * math.log2(p) for p in aff.values())
top = max(aff, key=aff.get)
label = f"specialist: {top}" if aff[top] >= 0.45 and ent < 2.2 else "generalist"
atlas[f"{layer}:{eid}"] = {"counts": counts, "affinity": {c: round(p, 3) for c, p in aff.items()},
"entropy": round(ent, 2), "top": top, "label": label}
spec_n = sum(1 for v in atlas.values() if v["label"].startswith("specialist"))
with open(a.out, "w") as f:
json.dump({"categories": cats, "ngen": a.ngen, "experts": atlas}, f)
print(f"\natlas: {len(atlas)} experts characterised, {spec_n} specialists -> {a.out}")
if __name__ == "__main__":
main()