Compare commits

...
86 Commits
Author SHA1 Message Date
Niko1221 b500a81e4e License: MIT (the ggml code, the font and the projection vector keep their own licenses) 2026-09-28 14:29:00 +02:00
Niko1221 26cdd4cde7 Engine 0.1.16; setup requires it (the Coder needs the model's own expert count, PR #54) 2026-09-28 14:27:45 +02:00
Niko1221 47a83696e9 The GSQ-RCO Coder (256 of 512 experts): setup, profile, speeds (PR #54)
- setup: a third family, "coder" (IQ1_M), with its own expert profile; its
  shard 2 and vision encoder are the original's files and are reused; 32 GB of
  RAM is now enough to install (the Coder's experts are 23 GB).
- data/expert-profile-coder.bin: the shipped ranking mapped onto the kept
  experts through the release's rco-allocation.txt (72% GPU hits, 12 GB card);
  tools/make_profile.py takes --n-expert.
- README/DETAILS: the Coder's speeds (prompts 599-1,350 tok/s, output
  43-53 tok/s, 1K-262K) and what it is; bench/results/2026-09-28-coder.
- The experimental speed projection can be turned on for the Coder too.
2026-09-28 14:20:58 +02:00
Niko1221 ef206bb671 Merge PR #54 (pjgmobile): pruned expert variants (GSQ-RCO Coder: 256 of 512 experts) 2026-09-28 13:45:47 +02:00
Niko1221 71c60dafce setup: model files in a data folder next to Strata (no re-download after an update); freeze warnings
- The model files (models/, packs/, mtp/: 70-120 GB) live in Strata-data next to
  the Strata folder, remembered per user (%APPDATA%\Strata\settings.json,
  ~/.config/strata/settings.json). Updating by unzipping a new copy used to give
  an empty folder and a full download again. Model files of this folder and of
  earlier Strata folders on the same drive are moved there (a rename) and their
  configs repointed; files on another drive are used where they are. A new copy
  with no config sets itself up like the last install, reusing the files.
  --data-dir chooses another place.
- The start says, before and while it happens, that the PC can be slow or stop
  responding for 1-3 minutes while the experts are loaded and locked: setup's
  start box, and the server narrates the engine's start from its log (weights,
  experts + the warning, expert cache, ready, a "still starting" line every 20 s).
2026-09-28 13:45:39 +02:00
pete 97ae3b2418 Merge remote-tracking branch 'origin/main' into pruned-geometry-support
# Conflicts:
#	src/prefill/prefill.cpp
2026-09-28 12:34:36 +01:00
pete 8bef4161cf Support pruned expert variants (GSQ-RCO Coder: 256 of 512 experts)
- iq_pack.py: derive n_expert from the router tensor shape (was hardcoded 512)
- expert_layout.cpp: parse the pack header's recorded n_expert instead of the
  caller's compile-time canonical geometry; use it for the contiguity stride
- gguf_reader.hpp: Qwen4ExpGuard expert-count checks become presence-only
- generate.cpp: read qwen4exp.expert_count/expert_used_count from the model
  file into the runtime geometry; MTP drafter keeps canonical geometry (the
  draft is the original 512-expert head); serve gate allows profile-less
  caches; K from metadata
- layer.cpp: native fused router stays 512x10-only, generic top-k fallback
- prefill: expert count taken from the geometry everywhere; route_kernel
  templatized (512->16, 256->8 registers/lane, else generic router_top10)
2026-09-28 12:29:32 +01:00
Niko1221 256485621e docs: troubleshooting row for the IQ3_XXS long-prompt stop (fixed in 0.1.15) 2026-09-28 13:18:18 +02:00
Niko1221 9ac549fd32 Engine 0.1.15; setup requires it (kernels loaded at start: IQ3_XXS at 64K+ no longer runs out of VRAM mid-prompt) 2026-09-28 13:17:58 +02:00
Niko1221 06f41f49a8 docs: speed tables measured with engine 0.1.14 (prompts 1.2-2.4x faster than the old tables) 2026-09-28 13:07:35 +02:00
Niko1221 bef32ed6c3 engine: load every CUDA kernel at start (CUDA_MODULE_LOADING=EAGER)
With lazy loading, a kernel first used mid-prompt found no VRAM left for its code
once the expert cache and the prompt path had taken it: IQ3_XXS at 64K and 128K on
a 12 GB card ended with 'out of memory: cudaFuncSetAttribute' in the MMQ launch.
Loaded at start the kernels cost ~30 MB, accounted before the cache is sized
(~23 fewer slots). A user's own CUDA_MODULE_LOADING is kept.
2026-09-28 12:49:25 +02:00
Niko1221 dcb0c24422 docs: Linux updates, other distributions and WSL; the 0.1.14 stall fix; the full expert profile 2026-09-28 11:46:37 +02:00
Niko1221 235cf4bd09 serve: the prompt progress line says how far the engine is (N of M tokens), not only the prompt's size (#29) 2026-09-28 11:22:55 +02:00
Niko1221 b38d11761a Engine 0.1.14; setup requires it (the PCIe share without host CUDA calls in a verify window, #31) 2026-09-28 11:21:01 +02:00
Niko1221 fbbfb9ce60 Merge PR #43 (pipeob0): AVX2 multi-token i-quant row kernels 2026-09-28 11:04:17 +02:00
Niko1221 5c52c0fd00 Merge PR #42 (pipeob0): MEM_LARGE_PAGES on Windows - enable the privilege, round the size 2026-09-28 11:02:44 +02:00
Niko1221 9253c92ad7 The PCIe share by the copy kernel for every pack (#31, #29); setup and profile fixes (#46, #49, #45)
- --pcie-mode auto is the copy kernel for native packs too. Issue #31's thread
  dumps show the host stuck for good in the DMA path's cudaMemcpyAsync (a driver
  lock) inside a verify window while the GPU spins on the flag those copies
  raise. The copy kernel needs no host CUDA call there; IQ3_S decode 45.3 -> 44.8
  tok/s. --pcie-mode dma keeps the old path.
- setup: a locally compiled engine records its version; one kept after a failed
  compile is recognised by the version in its binary, and a config it cannot
  read (--prefill auto on 0.1.12) goes back to 2048 (#49).
- setup: nvcc in /opt/cuda (Arch), a clearer message off Ubuntu/Debian (#46);
  a failed build is tried once more (#45: ptxas's intermittent PTX parse error).
- data/expert-profile.bin ranks all 24,576 experts (the first 8,000 unchanged):
  the cache stopped at 8,000 slots on 24-32 GB cards (#46). tools/make_profile.py
  builds or extends a profile from --dump-routing traces; --expert-cache 0 with a
  profile now means auto.
2026-09-28 11:02:44 +02:00
pipeob0 d7a66bd33e generate: say which expert kernels an AVX2-only CPU gets
The notice printed 'the expert kernels run on AVX2' whether or not the AVX2 i-quant row
kernels were used, and with STRATA_NO_IQ256 set they are not: the run is on ggml's vec_dot.
Name the kernel, and name the switch that changed it.
2026-09-28 09:51:47 +02:00
pipeob0 d1b30db332 pinned: say how many bytes were refused, and stop blaming the privilege
The refusal note blamed SeLockMemoryPrivilege whatever VirtualAlloc answered, and did not
say what was asked for: 1450 (ERROR_NO_SYSTEM_RESOURCES, the large-page pool has that many
2 MB pages free right now), 87 (a size that is not a multiple of the minimum) and 1314 (the
privilege is genuinely off) all read as the same one line.  Observed for real on a 5700X3D
box: a run minutes after an engine was killed printed 1450 while the next one succeeded -
the killed process had not released its pinned large pages yet, which is only visible if the
byte count and the error are both in the log.
2026-09-28 09:51:47 +02:00
pipeob0 0015035642 parity tool: guard the AVX-512 calls, and check the AVX2 kernels too
The tool called the AVX-512 row kernels and the AVX-512 quantizer unconditionally, so on
an AVX2-only CPU it died with SIGILL before printing anything - the one tool you want on
that machine.  Both calls are now behind cpu_avx512_ok().

Adds a block that runs the new AVX2 kernels directly against ggml's vec_dot on Q8_0
activations (the IQ4_NL down rows' real input) and times them, so a kernel change can be
validated and measured without the engine.
2026-09-28 09:51:47 +02:00
pipeob0 89c62e5361 kernels: AVX2 multi-token i-quant kernels for an AVX2-only CPU
The native pack's i-quant gate/up rows fell back to ggml's single-token vec_dot on a
CPU without AVX-512 (Ryzen 5000 series, Ice Lake, ...): the weights were decoded once
per token, and the 5700X3D's pool was decode bound.

iq_avx2.cpp adds multi-token row kernels for IQ2_XXS/IQ2_XS/IQ3_XXS/IQ3_S/IQ2_S and
IQ4_NL down rows, and for IQ2_XS the vectorized sign decode of ggml's AVX2 vec_dot
(the 8th sign bit by parity through the bit_helper pshufb, the sign vectors out of
shuffles of one 32-byte load, the 8 scale bytes through the unpack trick), with an
even_signs table equivalent to keven_signs_q2xs.  A row is decoded once and every
token in the verify window only pays a load, a sign, a maddubs, a madd and an add.

Measured on a 5700X3D + RTX 5060 Ti, Swift native pack, greedy, 300 tokens, medians of
interleaved A/B pairs: gate/up 15.16 -> 13.84 ms/round, rows 22.7 -> 23.8 GB/s,
48.5 ms/round vs 50.9 (-4.7%).  Single thread, one expert's gate+up: IQ2_XS 3 tokens
536 -> 416 us, IQ2_XXS 390 -> 337 us.  IQ4_NL down rows 248 -> 174 us for 3 tokens.
native_expert_parity: rel 3.1e-08 vs ggml's vec_dot, 0 failures.

STRATA_NO_IQ256 and STRATA_NO_IQ4NL fall back to ggml per format.
2026-09-28 09:51:47 +02:00
pipeob0 97cf327c1e pinned: enable SeLockMemoryPrivilege and round the arena to GetLargePageMinimum
reserve() asked for MEM_LARGE_PAGES without ever enabling SeLockMemoryPrivilege in the
process token, and passed the arena size unrounded.  VirtualAlloc requires an exact
multiple of GetLargePageMinimum, so the call failed with 87 (ERROR_INVALID_PARAMETER)
and the log blamed the privilege.  Both fixed; STRATA_NO_LARGEPAGES skips the attempt
and the note now says what actually happened.
2026-09-28 09:51:46 +02:00
Niko1221 6c06571710 Linux/WSL: engine errors end the engine and reach the client
- The --serve stdin reader reads the descriptor instead of std::cin: glibc's
  exit() waits for stdin's lock, which getline held while waiting for input,
  so an engine ending on any error hung in exit() and the server waited forever.
- session_init fails when a QSA layer could not pin its KV streaming copy; it
  used to continue half-built and hit an illegal memory access at the first
  request (seen under WSL, where the driver pins only about 1 GiB). The message
  says how much was pinned and what to change.
- setup: no KV streaming under WSL (new installs and existing configs).
- server: an engine ERR after an SSE stream started is sent as an error event
  (it was a 400 response written into the open stream: an empty answer), and
  the request is recorded as "error".
2026-09-28 08:51:22 +02:00
Niko1221 fb9b924e5c verify.cpp: include <cmath> (engine 0.1.13 did not compile on Linux: std::isfinite)
MSVC brought <cmath> in through another header; GCC does not. Found by building in WSL (Ubuntu 24.04, CUDA 13.0)
through setup.sh the way a Linux user does.
2026-09-28 08:03:22 +02:00
Niko1221 ffcdb437b8 Linux setup fixes, one pool worker per physical core on Linux, --port for installs, Monitor totals
- setup.sh is executable in the repository (a fresh clone could not run `./setup.sh`: Permission denied).
- setup.sh: Debian/Ubuntu ship Python's venv without ensurepip (the separate python3-venv package); the check
  now needs both, so it installs python3-venv instead of failing to create .venv - and a .venv left without pip
  by a failed run is made again.
- Linux: physical_cores() keeps one allowed CPU per (package, core) from sysfs, like the Windows branch; SMT
  siblings no longer get their own pool worker (issue #40: 23 -> 15 workers on a 13700K, +16% decode).
- START-HERE / setup.sh `--port N` now applies to an existing install; without it the install's own port is
  kept (issue #32).
- Monitor: the server keeps the last 500 requests and totals since it started; the Monitor shows the totals and
  a "Show all" toggle (`/metrics?requests=all`), 12 rows otherwise (issue #35).
2026-09-28 07:55:39 +02:00
Niko1221 2838cbae46 prompt path: never write into the borrowable cache slots while laying out (a resident expert was corrupted)
When serving, the prompt path lays its buffers out at start in the expert-cache slots it may borrow, but those
slots hold resident experts until a request lends them. Writing the MMQ row table at layout time overwrote part
of a resident expert: decode later computed it on the GPU, and the first window that routed to it turned every
logit into NaN ("!!!!" from mid-answer, e.g. at position 138 of an 86-token prompt, even with the prompt read
through the windows). The table is written at the start of every prompt read, while the slots are lent.
STRATA_DBG_NAN=1 also reports the first verify window with non-finite logits and the state a prompt leaves.
2026-09-28 04:56:25 +02:00
Niko1221 9ee557f781 prompt path: zero the tail after the last gathered expert before MMQ (garbage answers after the first request)
MMQ reads up to one 256-value tile past a matrix's last row when the row is not a multiple of it (the down
product: 640 values). Those weights meet zero activations, harmless only if they decode finite; llama.cpp
zero-pads after every tensor. The group buffers held leftovers - after the first request's refill, other
experts' bytes read as IQ4_NL blocks - and a non-finite scale times zero gave NaN logits ("!!!!" answers from
the second request on). The group buffers get a 4 KB tail, zeroed after the last expert before each launch.
The MMQ row table (in the lent slots the refill overwrites) is written at every prompt.
STRATA_DBG_NAN=1 reports the first layer of a chunk whose MoE produced non-finite values.
2026-09-28 04:19:42 +02:00
Niko1221 7cf709e5b1 engine 0.1.13: about 2x faster long prompts; stall reports for issue #31
Prompt path (bench/results/2026-09-28-prefill-speed):
- `--prefill auto`: chunks up to 8192 tokens, sized to the cache slots the prompt path may borrow; a
  request borrows only what its prompt needs (Prefill::relayout). setup writes it and switches old
  `--prefill 2048` configs once the engine supports it.
- The attention and MoE halves share one scratch region (~260 KB less per token).
- Experts through llama.cpp's MMQ kernels (ggml-cuda mmq.cuh, compiled from the same checkout as ggml
  into strata_mmq; Strata Q2_0 blobs converted to GGUF blocks on the gather; IQ1_M layers keep FP16).
- Chunks >= 2048: every non-resident expert streams through a ring in a fixed order across layers, so
  the next layer's arrive during its attention half (384 slots when the arena is pinned, else 96).
- The PLE block for the whole chunk at once; the next chunk's PLE rows read on a thread; unpinned
  experts copied by stager threads (epoch-tagged claims).
- STRATA_PREFILL_TIMING=1 prints a per-phase GPU timeline.
Q2_0 32K prompt 572 -> 1290 tok/s, IQ3_S 383 -> 1208 (RTX 5070 12 GB). Needles 5/5 1K-262K.

Issue #31 / #29: the serve watchdog (now 60 s) prints a stall report - each expert-pool thread's state,
the verify window's flags, layers served during the stall, memory and page faults - and on Windows
writes a minidump with every thread's stack; Release builds keep a PDB. The engine prints its version.
2026-09-28 02:03:00 +02:00
Niko1221 0f3ff4dece setup: recompile a locally built engine when its source changed (git pull on Linux, issue #31)
A compiled engine was stamped only as source=local, so later runs never rebuilt it and a
git pull kept the old binary (on Linux, where there is no ready-made engine, that also meant
no v0.1.12 pool fix). BUILD.json now keeps a hash of the engine's and the image encoder's
source files; on start a changed hash recompiles (incrementally, in build/) before the model
starts, and a failed compile falls back to the installed engine.
2026-09-27 21:40:19 +02:00
Niko1221 6255c8fd14 web app: attach text files - drop them on the chat or use the attach button (issue #30)
Text files (code, notes, logs, data; up to 512 KB) become chips next to
the message and are sent as "File: name" plus a fenced block (a longer
fence when the file contains one). Binary files are refused with a toast;
pictures work as before (when images are on). The attach button is always
there now. Kept small on purpose: the web app is for quick tests and
settings.
2026-09-27 20:53:38 +02:00
Niko1221 9ffd6c9461 engine: fix a race in the CPU expert pool, and never hang silently again (issue #29, engine 0.1.12)
On a card with most experts in VRAM (RTX 4090) generation could stop for
good after thousands of tokens: the GPU "100%" at low power, one CPU core
busy, no tokens, no error.

- The pool's workers sleep after 20 ms without work (issue #4). With most
  experts resident many layers have no CPU work, so they also slept
  mid-request, and a worker that woke late was still counted as parked: it
  could claim from the next batch while the host was writing it, run a job
  twice, or add to `done` after the reset - and `done != n` then never
  ended while the GPU waited on the pool. Every claim now carries its
  batch: `head` is one 64-bit word (epoch | njobs | index), a claim is a
  CAS that only succeeds for the epoch the worker woke for, and the host
  waits with `<` instead of `!=`. Same speed (pool_test 47.2 GB/s).
- Every pool wait is bounded (60 s: a message and the engine stops), and
  --serve runs a watchdog: a request with no progress (tokens, prompt
  chunks, verify windows) for STRATA_WATCHDOG_S (120) seconds ends the
  engine with where it was stuck (strata/core/progress.hpp). The server
  (issue #27) answers with an error, names the cause from the log, and
  starts the engine again on the next request.
- pool_stress: tiny batches with sleeping workers; STRATA_POOL_SPIN_US
  shortens the spin to make them sleep constantly.

Tested: pool_test, pool_stress; 25,000-token generation at 262K with KV
streaming and constantly sleeping workers (73.9 tok/s, unchanged); the
watchdog deliberately tripped (2 s): error, log line, restart, next answer.
2026-09-27 20:53:38 +02:00
pete 898d6a4c3e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	serve/server.py
2026-09-27 17:10:41 +01:00
Niko1221 1e3bded2f7 web app: "Use for other apps too" - the Chat settings as every client's defaults
A switch in the Sampling drawer. With it on, Apply stores the drawer's
settings on the server (POST /settings): thinking level, temperature,
top-p, top-k, seed, max tokens and the experimental speed projection. omp
and other API clients then get them for anything they don't send
themselves, from their next request (no restart); what a client sends still
wins. Off again, clients use their own settings.

- Kept between starts in <config>.shared-settings.json next to the run
  config; the server window says what other apps use.
- Only Strata's own page can change them: JSON only (a cross-site form or
  simple request can't send it without a CORS preflight, which this server
  never grants), no foreign Origin, and the API key when one is set.
- Values are checked (bad ones are a 400 naming the setting).
- Tests: defaults reach the engine, a client's own values win, off clears
  them and the file, 415/403/400/401.
2026-09-27 17:50:38 +02:00
Niko1221 ae3dc218ea web app: a long switch label no longer squeezes the switch
The Sampling drawer's "Experimental speed projection" row has a longer label
than "Show thinking", and the flex row shrank its switch, clipping the knob
at the drawer's edge. Switches in toggle rows keep their size.
2026-09-27 17:45:18 +02:00
Niko 3cba59ea31 Update README.md 2026-09-27 17:48:41 +02:00
Niko ecba7dfc10 Update README.md 2026-09-27 17:46:12 +02:00
Niko1221 1314ebfc7f README: a video up top, speed and model choice first, simpler troubleshooting (engine 0.1.10)
- An animated preview of the pagoda garden demo under the title (the full
  video is a release download: GitHub does not play videos from a
  repository), and a screenshot of the app in "Using it".
- "Is my PC enough?" folded into Install; "How fast is it?" and "Which model
  should I pick?" come first. The model table shows shard 1 (what gets
  loaded) instead of the download, with a plain fit rule: RAM >= shard 1 +
  ~10 GB, since the experts live in RAM.
- "Something went wrong?" as short questions and answers, including the
  first-start freeze and "the engine stopped unexpectedly".
- DETAILS troubleshooting: the same two rows, and the VRAM stall row now
  points to engine 0.1.9.
- setup requires engine 0.1.10 (INFO arena_mib, the start-up RAM warning).
2026-09-27 17:30:09 +02:00
Niko1221 7664149081 serve: an engine that dies mid-answer is an error, and the next request restarts it (issue #27)
On Linux with too little RAM for the model's experts (IQ3_XXS on 47 GB), the
out-of-memory killer ended the engine mid-answer. The server then recorded
the reply as "length" ("Max tokens" in the Monitor, no tok/s), the stream
was cut with a traceback, and every later request failed on the dead pipe.

- The engine's death is EngineDied: the reply ends with a clear error event
  (OpenAI and Anthropic streams; 503 before the answer), the Monitor says
  Error, and the server window names the likely cause (RAM; dmesg).
- The next request starts the engine again (same command) instead of
  failing forever; the engine counts as gone as soon as its output closes,
  and a failed write to it is not mistaken for the client leaving.
- The engine's INFO line reports its expert arena (arena_mib); at start the
  server warns when less than ~6 GB of RAM is left beside it.
- Test: a fake engine dies mid-stream -> error event + [DONE], Monitor
  "error", the next request restarts it and answers. End to end with Q2_0:
  strata.exe killed mid-answer -> error (exit code 1); next request restarts
  it (31 s) and answers.
2026-09-27 17:23:55 +02:00
Niko1221 6abee7f397 Experimental speed projection: an optional control vector (EXPERIMENTAL, off by default)
The engine takes llama.cpp's control-vector flags (--control-vector-scaled
FILE:SCALE, --control-vector-layer-range A B, --cvec-mode add|project,
--cvec-dir per-layer|single:L) and applies the vector after each steered
layer's FFN write on every hyper-connection stream: project h -= s (h.v) v,
or add h += d - where the experimental-speed-projection package's patched
llama.cpp applies it (build_cvec after the layer's second combine).

- strata/kernels/cvec: one kernel per (stream, token); with a pending fused
  write it applies it first with the fused read's own arithmetic, so a
  loaded-but-off vector is bitwise the stock engine. cvec_parity tests it.
- Per request: cvec=0|1 on the GEN/GENI line, the API field
  "experimental_speed_projection", a switch in the web app's Sampling
  drawer; a switch drops the conversation cache once. About and the
  Monitor say which way each request ran.
- setup asks (default off; --experimental-speed-projection on|off|GGUF),
  original Qwen3.8-Flash-Next only. The vector ships in
  data/experimental-speed-projection with a README that says what it is:
  its package describes it as a refusal-direction projection.

Measured (bench/results/2026-09-27-esp): off = stock token for token; on
costs 0.2-0.4% per token; mean KL 0.063 on 2,557 teacher-forced tokens.
2026-09-27 16:47:45 +02:00
Niko1221 868dc724d6 serve: reachable from other devices on the network (issue #26)
--host 0.0.0.0 bound fine, but nothing in the normal flow could set it: the
generated run scripts and setup's start always used the default
127.0.0.1, the config had no host, and the server then printed
http://0.0.0.0:8080, which opens nothing on another device.

- "host" in strata-<model>.json (the run scripts need no change); setup
  takes --host and --api-key and writes them there.
- With a non-local host the server prints this PC's addresses (the default
  route's first), warns when there is no API key, and on Windows says how to
  let it through the firewall. The local lines name 127.0.0.1.
- Documented in DETAILS.
2026-09-27 16:42:27 +02:00
Niko1221 f1b35531c3 engine: size the expert cache around the output head, so VRAM stays free (engine 0.1.9)
The expert cache takes what is free minus --vram-reserve-mib (700), so
everything allocated after it comes out of that reserve. The native output
head (0.44 GB Q2_0, 0.52 GB IQ3_S) was loaded after it and ate most of it:
IQ3_S at 128K ended with 30 MiB free, the driver paged GPU memory, and a
request stalled for good at its first verify window (GPU "100%" at 45 W).
Q2_0 at 32K ended with 75 MiB.

The head now loads before the cache is sized. Same reserve, now actually
free: 480-626 MiB with everything loaded (Q2_0 32K, IQ3_S 128K + vision).
The cache is ~8% smaller (Q2_0 32K: 3,848 slots vs 4,167) and decode is not
slower: 78-79 tok/s median vs 71.7 for 0.1.8 on the same greedy prompts.

Below 256 MiB the startup line now says which --vram-reserve-mib to add.
2026-09-27 16:23:07 +02:00
Niko1221 20351c6030 serve: --fit-max-tokens follow-ups (PR #24)
- A prompt that leaves no room at all is still the clear 400 with the flag
  on (it clamped to 1 token, which the engine then refused as over the
  context).
- "fit_max_tokens": true in the run config switches it on too, since the
  run scripts are generated by setup.
- Tests: an overshoot is clamped to the room on both APIs, a budget that
  fits is unchanged, no room is a 400. Documented in DETAILS.
2026-09-27 16:12:38 +02:00
Niko1221 bbba5bf576 Merge PR #24: optional --fit-max-tokens (clamp the output cap to the context instead of 400) 2026-09-27 16:11:54 +02:00
pete ba8c55e1cc serve: optional --fit-max-tokens clamps the output cap to remaining context instead of rejecting
Agentic clients legitimately request the model's full output cap with a long
prompt (e.g. prompt 131,888 + max_tokens 130,298 on a 262,144 window: rejected
over a 42-token overshoot). Default behaviour is unchanged: without the flag
the server still rejects with 400, like llama.cpp. With --fit-max-tokens the
server clamps max_tokens to the remaining room (max_context - CTX_SLACK -
prompt, floor 1 token) and answers.

Measured on a 262,144-token host: prompt 251,455 + max_tokens 131,072 ->
HTTP 200, completion_tokens 10,681 (= 262,144 - 251,455 - 8), finish length.
Flag off: byte-identical 400. Small requests unaffected in both modes.
2026-09-27 13:24:23 +01:00
pete b78d9da751 Merge remote-tracking branch 'origin/main' 2026-09-27 13:24:23 +01:00
pete 5f563ce3a3 serve: optional --fit-max-tokens clamps the output cap to remaining context instead of rejecting
Agentic clients legitimately request the model's full output cap with a long
prompt (e.g. prompt 131,888 + max_tokens 130,298 on a 262,144 window: rejected
over a 42-token overshoot). Default behaviour is unchanged: without the flag
the server still rejects with 400, like llama.cpp. With --fit-max-tokens the
server clamps max_tokens to the remaining room (max_context - CTX_SLACK -
prompt, floor 1 token) and answers.

Measured on a 262,144-token host: prompt 251,455 + max_tokens 131,072 ->
HTTP 200, completion_tokens 10,681 (= 262,144 - 251,455 - 8), finish length.
Flag off: byte-identical 400. Small requests unaffected in both modes.
2026-09-27 13:11:09 +01:00
Niko1221 6c431c60cc Record PR #21's rebased head as merged (its change is already in via 7f21e2b) 2026-09-27 12:14:51 +02:00
Niko1221 822a5c783f Web app: ship the Outfit font (OFL); the theme follows the system until picked; engine 0.1.8
- serve/web/fonts: Outfit as one variable font (latin + latin-ext, weights
  100-900) with its OFL.txt, in place of the design kit's commercial font.
- The page no longer saves the system's theme on first load (which froze
  it); only a click on the theme button or toggle is remembered, and the
  page follows system changes until then.
- setup: requires engine 0.1.8 (--kv q4_0, the INFO line).
2026-09-27 12:13:45 +02:00
Niko1221 0d721cf87b Web app: Chat, Monitor and About tabs (#22)
The browser page at http://127.0.0.1:8080 is now the Strata app, in the
Strata design language (serve/web, same origin, no CDN):
- Chat: streaming answers, collapsible thinking, Markdown with code copy,
  images when enabled, sampling/thinking settings, export, new chat.
- Monitor: live model state (reading with progress / generating at tok/s),
  GPU load, VRAM, temperature, power, PCIe traffic, CPU, RAM, disk, the
  context fill and the last requests, each with a minute of history.
- About: the model and engine settings, this PC, the API addresses.

Server: GET /metrics (API key when set), /web/* app files, /fonts/* only
when installed locally (not shipped; the page falls back to the system
font). serve/telemetry.py reads NVML via ctypes and psutil (new setup
dependency) with fallbacks. The engine prints an INFO line before READY
(kv, expert slots, speculation); older servers ignore it and the server
works with older engines.

Based on the dashboard idea in PR #22 by code-martin.
2026-09-27 12:00:33 +02:00
Niko1221andcode-martin 40542e8bb3 Merge PR #21: Q4_0 KV with Hadamard rotation (--kv q4_0), integrated with KV streaming; accurate expert-cache note (#23)
PR #21 (code-martin): a 4-bit K/V format (orthonormal FWHT-256 rotation of K, V and Q, then q4_0 blocks). On merge:
- Q4 is a full third format of the KV-streaming code (host copy, block residency, prompt-path staging, MTP ring,
  STATE_HASH): with --kv-resident the PR wrote K/V through the residency map's -1 entries. kv_stream_parity now
  checks Q4 streamed vs resident, bitwise.
- The per-token path ran attention twice (once before the blocks were resident); once now.
- The q4 group maximum breaks |x| ties the same way in every lane (one scale per block).
- Dropped: the duplicate path fixes (in 0.1.7), personal .gitignore entries, the re-encoded CMakeLists.
- Measured (bench/results/2026-09-27-kv-q4): int8 matches fp16; q4_0 costs precision on long documents
  (perplexity +8-12%), needles 5/5, +3.7% decode at 128K. Opt-in: setup asks above 8K context (int8 recommended),
  START-HERE.bat --kv q4_0, or --kv q4_0 in the config.

#23: the start-up warning "the GPU hit path is NOT CORRECT" dated from a long-fixed fault. Measured teacher-forced
on 2,557 tokens (bench/results/2026-09-27-cache-parity): cache on vs off, 95-98% same top-1 and equal perplexity
(-0.005 +- 0.005 nats); the warning is now one accurate line.

Co-Authored-By: code-martin <[email protected]>
2026-09-27 11:36:28 +02:00
code-martin e8b75ff2f6 feat(kv): add Q4_0 KV cache mode with FWHT-256 Walsh-Hadamard rotation
- Implement Q4_0 KV quantization and dequantization kernels with scale/offset
- Implement fast Walsh-Hadamard transform (FWHT-256) rotation for outlier suppression
- Add Q4_0 attention decode support in qsa_decode_attn
- Add kv_q4_parity parity test verifying CPU vs CUDA numerical accuracy
- Support --kv q4_0 in generate CLI and runtime layer allocation
- Update .gitignore for build and repository hygiene
2026-09-27 11:48:16 +03:00
code-martin 7f21e2b401 feat(kv): add Q4_0 KV cache mode with FWHT-256 Walsh-Hadamard rotation
- Implement Q4_0 KV quantization and dequantization kernels with scale/offset
- Implement fast Walsh-Hadamard transform (FWHT-256) rotation for outlier suppression
- Add Q4_0 attention decode support in qsa_decode_attn
- Add kv_q4_parity parity test verifying CPU vs CUDA numerical accuracy
- Support --kv q4_0 in generate CLI and runtime layer allocation
- Update .gitignore for build and repository hygiene
2026-09-27 11:29:08 +03:00
Niko1221 bbe8944ed3 Prompt lookup that pays, sampling fixes, shallow-path setup fix (engine 0.1.7)
- Prompt lookup on by default: the suffix drafter's windows are taken only where a learned policy (measured
  acceptance per match length, measured window cost) expects more tokens per ms than the MTP window; lookup windows
  up to --spec + 2. Code edits +6-11%, other text unchanged, verified output identical. --suffix-draft 0 = off.
- Per-request sampling (#19) keeps greedy requests on the in-graph sampler (no extra launch per window), and draws
  row t of a window with Philox(seed, pos0 + t), so a seed replays the same text whatever the drafts were.
- tools: a shallow install (G:\Strata) no longer crashes the MTP packer and the pack tools (IndexError, #20).
- setup: says at step 1 when the PC has less RAM than the smallest model needs; requires engine 0.1.7.
2026-09-27 10:18:14 +02:00
Niko1221 54adac29a4 Merge branch 'pr-19' into integrate-017 2026-09-27 09:58:30 +02:00
Niko1221 e5eb7b2ae7 Merge branch 'pr-18' into integrate-017 2026-09-27 09:58:30 +02:00
Niko1221 8f279d82a0 KV streaming: the draft layer keeps its KV in VRAM when no pinned RAM is left (engine 0.1.6)
The MTP drafter's ring over a host copy is set up after the expert arena has pinned what it could; when that
pinned allocation fails the drafter now falls back to its fully resident KV instead of stopping the engine
("mtp: state init failed", seen with Swift 1.5 at 262K). setup requires engine 0.1.6.
2026-09-27 08:34:09 +02:00
Niko1221 0616dfb74e KV streaming for the QSA layers (engine 0.1.5)
From 64K context the KV cache lives in pinned RAM and a VRAM block cache holds the part the sparse attention reads
(--kv-resident N; setup turns it on with 32768 when RAM allows). Blocks the indexer selects are made resident on
the device inside the captured graphs (clock eviction, zero-copy reads of misses); writers write through, so the
attention reads exactly the same values. The freed VRAM goes to the expert cache: Q2_0 at 262K decodes 62.6 tok/s
instead of 50.9. The MTP drafter keeps its window in a ring. New ctest kv_stream_parity. Also wires the opt-in
suffix drafter (--suffix-draft) into the engine.
2026-09-27 08:29:03 +02:00
Jakub Luwierski b819fe6934 sampler: the penalty count as a shared bitmap
The penalties made the sampled path pay O(k x n_vocab x hlen) integer
compares per token: every top_k argmax round recounted every
candidate's history occurrences (k=20 rounds, hlen=64, ~318M compares
on the 248,320 vocabulary) - measured 45.2 -> 30.8 tok/s on a real
chat workload.  But the history touches at most hlen DISTINCT tokens,
so membership is a bit: each block now builds a shared bitmap from its
history row (atomicOr, one pass) and a candidate pays the O(hlen)
count scan only when its bit is set - at most hlen x k scans per token
instead of k x n_vocab.  Neutral values and the no-history path skip
the bitmap entirely.

The counts - and therefore every penalised value, kept token and
draw - are exactly what the per-candidate scan produced;
sampler_parity passes unchanged, plus a new fixture pinning the
window clamp (an 8-entry history at penalty_last_n=4 must punish only
the tail half).
2026-09-27 08:24:09 +02:00
Jakub Luwierski e3e1493df4 serve: the sampling block supports min_p and the penalties
min_p, presence_penalty, repetition_penalty and frequency_penalty move
from "warned and ignored" to the sampled path, and penalty_last_n sets
the window they count over - the server sends the engine's default of
64 when a penalty is set and the client left the window out.
sampling_keys forwards each field only when it is not neutral, so a
client (or a config) that leaves them out costs nothing.  The
validator range-checks the new fields and still refuses to start the
server on a value it cannot honour.

Verified: the validator accepts and range-checks min_p/presence/
frequency/repetition/penalty_last_n; the GEN line spells them the
engine's way and omits neutral values; the last-64 default appears
only when a penalty is actually set.
2026-09-27 07:55:19 +02:00
Jakub Luwierski 4703da4363 generate: min_p and the penalties ride the GEN line
The sampled chain's last two gaps close: `min_p` and the llama.cpp
penalty set (repeat/freq/present over a history window) are live end
to end - GEN keys, per-request params, and the token history the
penalties count over.

- GEN accepts min_p=F penalty_last_n=N penalty_repeat=F penalty_freq=F
  penalty_present=F between max_new and the ids (the server's
  spelling); the engine clamps defensively (min_p to 0..1, a negative
  last_n to 0) and skips what it does not know, as before.
- min_p cuts the top_k survivors to the prefix with p >= min_p * p_max
  - in logits, sel_logit >= sel_logit[0] + logf(min_p) - before top_p,
  so the two cuts compose; 0 disables, and the head always survives,
  so the kept count never reaches zero.
- the penalties need the token history: the serve loop uploads the
  last penalty_last_n tokens - the consumed tail plus the fed-back
  head, which joins consumed_seq only after the window commits - into
  one device row before every window.  A request without penalties
  hands the sampler a null row and takes the byte-for-byte neutral
  path (no upload, nothing handed over).

Verified: sampler_parity grows a full-chain host reference (the Philox
draw transcribed host-side) and fixtures for the sampled chain with
penalties and for min_p at 0/0.5/0.9 - the kernel matches the
reference on all of them, and each fixture first asserts it can SEE
the feature before asserting the kernel matches.  On the IQ3_XXS
artifact, same seed: a request with neutral penalty keys is
token-identical to one without them; a six-copy prompt that loops
24/24 on one token clean produces 24 distinct tokens with repeat 1.5
+ present 0.5; min_p 0.9 changes the pick.
2026-09-27 07:55:19 +02:00
Jakub Luwierski 9e6e5b7a1a serve: sampling defaults from the run config
The run config's optional `sampling` block (temperature, top_p, top_k,
seed) sets defaults for the fields a request leaves out, so a client
that sends no sampling keys gets configured sampling instead of greedy.
The request's own fields always win: an explicit temperature=0 still
means greedy, and a field set to null falls back to the default.

Keys the sampled path does not implement yet (min_p, presence_penalty,
repetition_penalty, frequency_penalty) are named at startup and ignored
rather than silently dropped; unknown keys are reported the same way.
An invalid value (top_k outside 1..64, a non-positive seed, a negative
temperature) refuses to start the server - a typo'd config should not
quietly change sampling.

Verified with a spy engine through Service.run: defaults apply when the
request omits the fields, the request's own values win, a null field
falls back to the default, and a server without the block forwards the
request unchanged; the config validator rejects out-of-range values and
warns on the not-yet-implemented keys.
2026-09-27 07:50:10 +02:00
Jakub Luwierski 2e9acd3d8b docs: the v1 limits line still said temperature was ignored
It contradicted the paragraph next to it, and since the sampling keys
now ride the GEN line (and the block-parallel sampler makes a sampled
window cost about what a greedy one does), it was simply false.
2026-09-27 07:33:52 +02:00
Jakub Luwierski b7d73e1b32 serve: forward temperature/top_p/top_k/seed to the engine
StrataEngine.generate accepted the request's sampling parameters and
dropped them, so every served request decoded greedy whatever the client
asked - the README's 'temperature / top_p / top_k / seed are honored per
request' was not true over the HTTP API, and the engine-side per-request
sampling was unreachable from it.

The keys ride the GEN line in the engine's own spelling; an absent
temperature keeps the engine's greedy default and temperature=0 is not
forwarded (it means the same thing); top_k outside the sampled path's
1..64 is dropped rather than sending a value the engine would refuse.

Verified end to end over the HTTP server (static residency for the
seeded leg): temperature=0 and absent agree token for token; a
temperature=0.8 request differs from greedy; two temperature=0.8
seed=99 requests return identical text.
2026-09-27 07:33:17 +02:00
Jakub Luwierski 8f3c21dc02 perf: sample the verify window's head with a block per token
The sampled chain ran in ONE thread per token (64-thread launch): top_k
alone was k sequential full-vocabulary scans with an inner sweep over the
taken list, and a verify window measured ~1.6 s in the sampler - sampled
requests decoded at 1.5 tok/s against 54 greedy.

The selection is k argmax rounds, so it parallelises exactly like the
greedy kernel: one block per token, the rounds back to back inside it,
each a two-level (warp, then block) reduction.  Ties resolve to the
lowest index (the serial scan's strict '>'), so the kept sequence - set
and order - is unchanged, and the top_p cut, temperature and Philox draw
read that order as before.  sampler_parity passes; a 248,320-vocab
GPU-vs-CPU draw check matches the serial reference on five parameter
sets including injected duplicate logits.

Measured (RTX 4070 TS, IQ3_XXS, 128-token generations): sampled decode
1.50 -> 42.7 tok/s; greedy decode unchanged (53.3 vs 54.3 tok/s median,
within run noise; greedy launches the untouched kernel).
2026-09-27 07:33:17 +02:00
Jakub Luwierski 90546d7428 docs: temperature sampling + reproducibility note (static residency) 2026-09-27 07:33:17 +02:00
Jakub Luwierski 7546c36d9d verify: a persistent per-request draw counter; sampled requests default top_k=20
The sampled path requires top_k in 1..64 (the selection loop is
O(k*n_vocab) by design); with the engine-default top_k=0 it returned
without writing the token, leaking stale buffer contents as output.
Sampled requests now default to the sampler's own top_k=20.

The Philox draw counter also must persist across a request's verify
windows - it was read from a run-local variable that reset every
window, so every window redrew the same numbers (identical sampled
output on identical requests).
2026-09-27 07:31:54 +02:00
Jakub Luwierski dea4f4f2b5 serve: per-request temperature sampling for the verify window's head
Temperature was ignored because the verify window's head sampling was
pinned to greedy INSIDE the captured CUDA graph (verify.cpp hardcoded
sp.greedy = true), and the GEN protocol carried no sampling keys.

- the sampling moves OUT of the captured graph: the graph computes the
  head logits, and run() applies sample_tokens host-side after the
  replay, with that request's own parameters and a fresh Philox counter
  per window (a captured sampler would replay the same draws forever)
- the GEN protocol accepts optional key=value pairs before the ids:
  temperature=, top_p=, top_k=, seed=; absent keys stay greedy, exactly
  as before
- server.py forwards the request's temperature/top_p/top_k/seed
- emitted tokens are always the TARGET's own samples (the drafts only
  fill the verified window), so the output distribution is the target's
  regardless of what the drafts propose

Building on the conversation cache: greedy requests stay bit-identical,
sampled requests with the same seed replay identically.
2026-09-27 07:29:40 +02:00
btechnet 656a835682 serve: treat unset/-1 max_tokens as remaining context, not 1024
A missing, null, 0 or -1 max_tokens / max_completion_tokens now means the
rest of the context (minus the 8-token margin strata --serve keeps), so
replies no longer stop at 1024 tokens. prepare() resolves the budget after
tokenizing and returns it; an explicit budget is honoured exactly and still
rejected when prompt + budget exceeds the context. Adds serve/test_server.py.
2026-09-26 20:27:30 -05:00
Niko1221 55e2fb8576 setup: require engine v0.1.4 (IQ3_S) 2026-09-26 23:57:44 +02:00
Niko1221 59b0696208 IQ3_S support: the GSQ-RCO IQ3_S model (engine 0.1.4)
- GPU experts: IQ4_XS gate/up (the IQ3_S file keeps layer 47's routed experts in it), transcribed from llama.cpp's
  vec_dot_iq4_xs_q8_1; checked on all 48 layers against ggml's float reference (native_expert_parity: 0 failures).
- MTP draft head: gather_rows takes rows of any size (the IQ3_S head is Q6_K, 2100-byte rows, not a multiple of 16).
- setup: IQ3_S for the original model (Swift 1.5 has none), 64 GB RAM, context capped at 128K like IQ3_XXS.
- README / DETAILS: IQ3_S speed (RTX 5070, Ryzen 5 7600, 64 GB): 54 tok/s at 4K, 42 at 128K, 374-397 prompt.
2026-09-26 23:55:41 +02:00
Niko1221 0223d32663 setup: update an old ready-made engine when an existing install starts
START-HERE.bat on an installed setup went straight to starting the model, so the MIN_ENGINE check (and the
engine download) only ran when setup was run again with its options.  Now every start checks the installed
engine and replaces it when it is older than MIN_ENGINE.  If it cannot (no internet, the model still running,
no ready-made engine for this GPU) the installed engine is kept and starts as before.
2026-09-26 22:24:29 +02:00
Niko1221 d0017a7c58 setup: require engine v0.1.3 (conversation cache, idle CPU fix, Linux pool fix) 2026-09-26 22:18:20 +02:00
Niko1221 0c2e63e930 Merge pull request #10: Conversation cache + short prompt parts through the decode windows (#8, #10) 2026-09-26 21:25:03 +02:00
Niko1221 888751a0e1 Merge pull request #7: serve: fix zero-token replies from the engine-queue race between requests (#7) 2026-09-26 21:25:03 +02:00
Niko1221 af1c91f245 Merge pull request #9: CPU pool: sleep between requests instead of spinning (#9, fixes #4) 2026-09-26 21:25:03 +02:00
Niko1221 14b22c51c1 Merge pull request #14: CPU pool: fix dangling else in Linux physical_cores() (#14, fixes #13 and #11) 2026-09-26 21:25:03 +02:00
pete ad29df2396 Merge branch 'pr-14' 2026-09-26 19:20:59 +01:00
Vistawizard be028948f4 CPU pool: fix dangling else in the Linux physical_cores() branch
Without braces the `else` bound to `if (CPU_ISSET(i, &set))`, so every CPU id
outside the affinity mask appended all hardware_concurrency() ids again:
16143 pool workers on a 16-CPU box, load > 10000 and a hang at start.
Brace both branches so the fallback only runs when sched_getaffinity fails.

Fixes #13, root cause of #11.
2026-09-26 19:23:15 +02:00
pete 8936605cc5 Merge branch 'pr-8' 2026-09-26 17:39:42 +01:00
pete 3503b5ddd8 Merge branch 'pr-7' 2026-09-26 17:39:42 +01:00
Mirtraxxx 470b3d7c21 serve: read short prompt parts through the decode windows
The batched prompt path costs ~300 ms per run however few tokens it
has (it streams every non-resident expert the chunk routes to over
PCIe), plus ~180 ms to refill the expert-cache slots it borrows. So a
one-word message waited ~1.4 s for its first token, and every chat
follow-up paid the same fixed cost.

Each part of the prompt (up to the checkpoint, then the assistant
header) now picks its own path: parts of at most --short-read N tokens
(default 64, 0 = off) go through the verify windows, S tokens at a
time, as decode reads them (~16 ms a token). Longer parts and parts
with picture rows stay batched. Slots are lent just before a batched
run and refilled before a window read, so windows always see the whole
expert cache. STRATA_CKPT_REREAD keeps every read batched.
2026-09-26 01:02:20 -03:00
Mirtraxxx be0e0c94e3 cpu pool: sleep between requests instead of spinning (fixes #4)
A parked expert-pool worker spun on `epoch_` with `_mm_pause` forever, so every
worker's core stayed at 100% while the engine waited for a request: 7 cores
(44% of a 16-thread Ryzen 7 5700X) with nothing to do.

A parked worker now spins as before for up to 20 ms, then blocks on a
condition variable. Every publish goes through `publish()`, which bumps the
epoch and notifies only when a worker is actually asleep (one uncontended
load otherwise). Inside a request the gaps between batches are microseconds,
so the token path never sleeps.
2026-09-25 21:27:53 -03:00
Mirtraxxx 42810ee8bb serve: conversation cache - keep the chat between requests, read only what is new
Every request used to start from session_zero and read its whole prompt again,
so a long chat or an agent loop waited minutes per turn. The engine now keeps
the live session and up to 6 checkpoints of the running state in RAM and reads
only the part of a prompt after the longest one it starts with.

- A checkpoint is the state that cannot be rewound - the 36 GDN recurrences and
  conv histories, the 12 QSA indexer tails, the PLE history (~118 MB). The KV
  cache and the pooled indexer keys are positional (a query reads only cells
  below it, and `dead` for its own block), so they stay in place.
- Taken at the last <|im_start|> of each prompt (the prompt is read in two parts
  there: the history, then the new turn's header) and every 16K fresh tokens.
- Used only when the prompt starts with exactly its tokens and pictures (images
  are compared by a hash of their embeddings and grid); checkpoints the next
  request would overwrite are dropped.
- New lines on stdout: RESUME <n> before reading, PP <pos> <n> <ms> <tok/s> per
  chunk, REUSED <n> after; DONE gains <drafts accepted> <drafts offered>
  <reused>. The server ignores what it does not know; PP lines now count as its
  heartbeat, so keep-alives keep flowing during a long prompt.
- --prompt-cache N (default 6, 0 = off), --prompt-cache-every N, --turn-token ID.
- Checks: STRATA_CKPT_REREAD=1 reads a checkpoint's tokens again instead of
  restoring it; STRATA_STATE_HASH=1 prints a fingerprint of every state part
  after each request.
2026-09-25 21:18:23 -03:00
btechnet 0f8b5d4938 serve: fix zero-token replies from engine-queue race between requests
Symptom: with two requests in flight (opencode fires a title-generation
request alongside the main tool-calling turn), the second request often
returned zero tokens. The server logged `done: 0 tokens in 0 s (length,
cancel=False)` and opencode showed an empty reply.

Cause: StrataEngine multiplexes every request over one resident
`strata --serve` subprocess and a single shared line queue (self.lines),
with no per-request framing. In Service.run(), a stop token (<|im_end|>)
breaks the token loop with the engine generator still suspended and its
internal `done` flag False. That generator's cleanup - write STOP, then
drain self.lines until DONE - therefore ran only when run()'s frame was
torn down later, AFTER the `with self.fifo` lock had been released. By
then the next request held the lock and had issued its own GEN; the prior
request's drain raced it on the shared queue and consumed the NEW
request's DONE. The new request saw an immediate end-of-stream: 0 tokens,
with `finish` left at its default of "length".

Fix: close the engine generator inside the `with self.fifo` block via
try/finally, so a request's STOP+drain completes while it still holds the
lock and cannot leave stray lines on the shared queue for the next
request to misread as its own DONE.

Also in this change:
- Clamp a non-positive max_new to 1024 in both /v1/chat/completions and
  /v1/messages. Some clients send max_tokens: -1 ("unlimited"); that value
  passed straight through and `GEN -1` made the engine yield 0 tokens.
- Distinguish a client disconnect: catch GeneratorExit and set
  finish="disconnect" instead of leaving it as "length".
- Diagnostics gated on STRATA_DEBUG (off by default): a per-request
  `_debug_req` line (api, msg/tool counts, raw max_tokens, resolved
  max_new, thinking flag, prompt tokens) and a `raw:` dump of the decoded
  model output at completion.
- Progress reporting: add tok/s and cancel state to the `done:` line, and
  emit the progress line every 1 s instead of every 15 s for live
  throughput.
2026-09-25 18:11:01 -05:00
Niko1221 07b4a6263a Engine v0.1.2: streaming tool calls, live status, real cancel; fix requests stalling on a full GPU
Streaming
- tool calls stream as they are written (OpenAI tool_calls deltas, Anthropic input_json_delta)
- keep-alives while a long prompt is read, progress in the server window, GET /status
- closing the connection or pressing stop really stops the engine (STOP)

Stalled requests (mostly image turns)
- the expert cache is sized after its slots are written (Windows only counts GPU memory once it is
  touched), so the reserve really stays free instead of the card running at 0 MiB and paging
- the prompt chunk shrinks when its buffers do not fit in the cache, instead of allocating on top
- the image encoder warms up before the engine starts; images are encoded in request order
- the log shows the VRAM left once everything is loaded

setup.py now requires engine 0.1.2 (downloaded automatically on the next START-HERE.bat).
2026-09-25 06:13:46 +02:00
Niko1221 e550970773 README: a short, beginner-friendly front page (details in docs/DETAILS.md); pictures in WebP/TIFF/AVIF 2026-09-24 23:17:29 +02:00
102 changed files with 12161 additions and 1104 deletions

No files matched your search

+1
View File
@@ -46,3 +46,4 @@ build-vision.bat
/dist/
*.done
/third_party/*.zip
+64 -4
View File
@@ -8,7 +8,14 @@
# written against sm_120 (RTX 5000 / Blackwell) and silently building for an older arch would produce a
# binary that runs and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
project(strata VERSION 0.1.0 LANGUAGES CXX)
project(strata VERSION 0.1.16 LANGUAGES CXX)
add_compile_definitions(STRATA_VERSION="${PROJECT_VERSION}")
# issue #31: release builds keep a PDB beside the exe (not shipped in it), so a minidump from a stalled engine can
# be read back to the exact source line
if(MSVC AND CMAKE_BUILD_TYPE STREQUAL "Release")
add_compile_options($<$<COMPILE_LANGUAGE:CXX>:/Zi>)
add_link_options(/DEBUG /OPT:REF /OPT:ICF)
endif()
# The prebuilt release (setup.py downloads it, so users need no compiler): ggml's CPU backend for any AVX2 CPU
# instead of this machine's (Strata's own AVX-512 kernels are per-file and chosen at run time either way), and
@@ -177,7 +184,10 @@ if(STRATA_ENABLE_CUDA)
src/kernels/cuda/native_qsa_score.cu
src/kernels/cuda/ple.cu
src/kernels/cuda/kv_q8.cu
src/kernels/cuda/kv_stream.cu
src/kernels/cuda/kv_q4.cu
src/kernels/cuda/fused_gr.cu
src/kernels/cuda/cvec.cu
src/kernels/cuda/qsa_decode_attn.cu
src/kernels/cuda/fused_gdn.cu
src/kernels/cuda/dequant_bf16.cu
@@ -250,7 +260,7 @@ if(STRATA_ENABLE_CUDA)
add_executable(strata src/program/generate.cpp)
target_include_directories(strata PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(strata PRIVATE strata_engine strata_prefill CUDA::cudart)
target_link_libraries(strata PRIVATE strata_engine strata_prefill strata_spec CUDA::cudart)
add_executable(dequant_s2_parity src/kernels/dequant_s2_parity.cpp)
# The tests are HOST C++ files that call cudaMalloc/cudaMemcpy themselves, so they need the toolkit headers
@@ -356,6 +366,22 @@ if(STRATA_ENABLE_CUDA)
target_link_libraries(kv_q8_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME kv_q8_parity COMMAND kv_q8_parity)
# KV streaming (docs/kv-streaming-design.md): streamed vs resident attention, bitwise (GPU, synthetic, no model).
add_executable(kv_stream_parity src/kernels/kv_stream_parity.cpp)
target_include_directories(kv_stream_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(kv_stream_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME kv_stream_parity COMMAND kv_stream_parity)
# PR #21: Q4_0 KV with Walsh-Hadamard rotation vs a host reference (GPU, synthetic, no model).
add_executable(kv_q4_parity src/kernels/kv_q4_parity.cpp)
target_include_directories(kv_q4_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(kv_q4_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME kv_q4_parity COMMAND kv_q4_parity)
add_executable(cvec_parity src/kernels/cvec_parity.cpp)
target_include_directories(cvec_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(cvec_parity PRIVATE strata_kernels CUDA::cudart)
add_test(NAME cvec_parity COMMAND cvec_parity)
# Plan v0.3 P3: native MMVQ for 1..8 tokens (bitwise selftest + --bench timing); synthetic data, no model.
if(STRATA_BUILD_TESTS)
add_executable(native_mmvq_multi bench/micro/native_mmvq_multi.cpp)
@@ -417,7 +443,7 @@ endif()
# worse opt-out. `cpu_require_expert_support()` therefore refuses at startup on a CPU without the
# features, and the fallback exists for tests, exactly as P2.S3 specifies.
# Plan v0.3 P6: speculation components that need no GPU.
add_library(strata_spec STATIC src/spec/suffix_drafter.cpp src/spec/controller.cpp)
add_library(strata_spec STATIC src/spec/suffix_drafter.cpp src/spec/controller.cpp src/spec/draft_policy.cpp)
target_include_directories(strata_spec PUBLIC include)
if(STRATA_BUILD_TESTS)
add_executable(suffix_drafter_test src/spec/suffix_drafter_test.cpp)
@@ -426,6 +452,9 @@ if(STRATA_BUILD_TESTS)
add_executable(controller_test src/spec/controller_test.cpp)
target_link_libraries(controller_test PRIVATE strata_spec)
add_test(NAME controller_test COMMAND controller_test)
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
target_link_libraries(draft_policy_test PRIVATE strata_spec)
add_test(NAME draft_policy_test COMMAND draft_policy_test)
endif()
add_library(strata_kernels_cpu STATIC src/kernels/cpu/expert.cpp src/kernels/cpu/pool.cpp
@@ -485,16 +514,43 @@ if(STRATA_NATIVE_EXPERTS)
endif()
# part of strata_kernels_cpu (the pool calls these and they call its CPU probe: one library, so GNU ld's
# one-pass static linking has no cycle to resolve)
target_sources(strata_kernels_cpu PRIVATE src/kernels/cpu/native_expert.cpp src/kernels/cpu/iq_avx512.cpp)
target_sources(strata_kernels_cpu PRIVATE src/kernels/cpu/native_expert.cpp src/kernels/cpu/iq_avx512.cpp
src/kernels/cpu/iq_avx2.cpp)
target_include_directories(strata_kernels_cpu PRIVATE third_party/ggml)
if(MSVC)
set_source_files_properties(src/kernels/cpu/iq_avx512.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX512")
set_source_files_properties(src/kernels/cpu/iq_avx2.cpp PROPERTIES COMPILE_OPTIONS "/arch:AVX2")
else()
set_source_files_properties(src/kernels/cpu/iq_avx512.cpp PROPERTIES COMPILE_OPTIONS
"-mavx512f;-mavx512bw;-mavx512vl;-mavx512dq;-mavx512vnni;-mavx512vbmi;-mf16c;-mfma")
set_source_files_properties(src/kernels/cpu/iq_avx2.cpp PROPERTIES COMPILE_OPTIONS "-mavx2;-mfma;-mf16c")
endif()
target_link_libraries(strata_kernels_cpu PUBLIC ggml-cpu ggml-base)
target_compile_definitions(strata_kernels_cpu PUBLIC STRATA_NATIVE_EXPERTS=1)
if(STRATA_ENABLE_CUDA)
# prompt-speed plan step 2b: llama.cpp's MMQ kernels (ggml-cuda, MIT) for the prompt path's experts, compiled
# from the same checkout as ggml above - only mmq.cuh's instances for the packs' types and the quantizer, with
# the few host symbols of ggml-cuda.cu they use in src/prefill/ggml_cuda_host.cu
if(STRATA_GGML_DIR)
set(_strata_gg ${STRATA_GGML_DIR}/ggml)
else()
set(_strata_gg ${strata_llamacpp_SOURCE_DIR}/ggml)
endif()
set(_strata_mmq ${_strata_gg}/src/ggml-cuda)
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/ggml_cuda_host.cu ${_strata_mmq}/quantize.cu)
foreach(_t q2_0 iq2_xxs iq2_xs iq2_s iq3_xxs iq3_s iq4_nl iq4_xs)
target_sources(strata_mmq PRIVATE ${_strata_mmq}/template-instances/mmq-instance-${_t}.cu)
endforeach()
target_include_directories(strata_mmq PRIVATE ${_strata_gg}/include ${_strata_gg}/src ${_strata_mmq}
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_options(strata_mmq PRIVATE $<$<COMPILE_LANGUAGE:CUDA>:--extended-lambda -use_fast_math>)
if(MSVC)
target_compile_options(strata_mmq PRIVATE $<$<COMPILE_LANGUAGE:CUDA>:-Xcompiler=/bigobj>)
endif()
target_link_libraries(strata_mmq PUBLIC ggml-base CUDA::cudart)
target_link_libraries(strata_prefill PUBLIC strata_mmq)
target_compile_definitions(strata_prefill PUBLIC STRATA_PREFILL_MMQ=1)
endif()
if(STRATA_ENABLE_CUDA)
add_executable(native_expert_parity src/kernels/native_expert_parity.cpp)
target_include_directories(native_expert_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
@@ -528,6 +584,10 @@ if(STRATA_BUILD_TESTS)
add_executable(pool_test src/kernels/cpu/pool_test.cpp)
target_link_libraries(pool_test PRIVATE strata_kernels_cpu)
add_test(NAME pool_test COMMAND pool_test --selftest WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
# issue #29: tiny batches with sleeping workers, as on a big-VRAM card; a hang fails it
add_executable(pool_stress src/kernels/cpu/pool_stress.cpp)
target_link_libraries(pool_stress PRIVATE strata_kernels_cpu)
add_test(NAME pool_stress COMMAND pool_stress 20)
endif()
if(STRATA_BUILD_TESTS)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Niko1221 and the Strata contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+139 -282
View File
@@ -1,332 +1,189 @@
<h1 align="center">Qwen3.8-Flash-Next (125B MoE) on a 12-24 GB NVIDIA GPU + 64 GB RAM: one-click install for Windows / Linux</h1>
<h1 align="center">Strata</h1>
<p align="center"><b>Strata</b>: an inference engine built for exactly one model and exactly this kind of PC</p>
<p align="center"><b>Run a 125-billion-parameter AI model on a normal gaming PC</b><br>
one NVIDIA card (12-24 GB) + 64 GB of RAM · Windows or Linux · one click to install</p>
A 125-billion-parameter mixture-of-experts model (6B active per token) on a **normal gaming PC**: one NVIDIA card with
12-24 GB of VRAM, 64 GB of RAM and a desktop CPU. Strata keeps the always-used part of the model on the GPU, all 24,576
experts in RAM, computes the experts the GPU does not hold on the CPU at the same time, and checks several tokens per
pass with the model's own multi-token-prediction (MTP) layer. The text it produces is identical to plain greedy decoding.
<p align="center"><a href="https://github.com/Niko1221/Strata/releases/download/v0.1.10/Pagoda.mp4"><img src="docs/media/pagoda-preview.webp" width="720" alt="A voxel pagoda garden that Strata's model wrote, running in the browser"></a><br>
<sub>A voxel pagoda garden, 1 shot prompt running on an RTX 5070 with Strata (IQ3_S, 128K context) ·
<a href="https://github.com/Niko1221/Strata/releases/download/v0.1.10/Pagoda.mp4">full video (49 s)</a></sub></p>
It serves an **OpenAI-** and **Anthropic-compatible API on localhost**, so any chat app, coding agent or script that
talks to those APIs can use it. Optionally it also **reads images** (screenshots, photos, scanned pages) with the
model's own vision encoder.
Strata runs **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** - a large, smart AI model that
normally needs a server - on your own PC. It writes its answers at **60-95 tokens per second** (a token is about ¾
of a word): faster than you can read.
> **Jump to:** [Speed](#speed-measured) · [Other GPUs](#other-gpus-estimated) · [Which model?](#which-model) ·
> [Windows](#windows) · [Linux](#linux) · [Using it](#using-it) · [Images](#images-vision) ·
> [Troubleshooting](#troubleshooting) · [How it works](#how-it-works)
- **Free and open source.**
> **Jump to:** [How fast?](#how-fast-is-it) · [Which model?](#which-model-should-i-pick) · [Install](#install) ·
> [Using it](#using-it) · [Problems?](#something-went-wrong) · [How it works](#how-does-it-work) ·
> [All the details](docs/DETAILS.md)
---
## Speed (measured)
## How fast is it?
RTX 5070 **12 GB**, Ryzen 5 7600 (6 cores), 64 GB DDR5-5200, Windows. One code-agent prompt per length, 256 generated
tokens, MTP speculative decoding on. "262K" is the model's full context window (a 259,943-token prompt).
Measured on an RTX 5070 (12 GB), a Ryzen 5 7600 and 64 GB of RAM:
### Prompt processing (tokens/s)
| Size | Writes answers (short chat) | Writes answers (128K context) | Reads your prompt |
| --- | ---: | ---: | ---: |
| **Q2_0** | 90 tokens/s | 67 tokens/s | 1,310 tokens/s |
| **IQ2_XS** | 74 tokens/s | 60 tokens/s | 1,240 tokens/s |
| **IQ3_XXS** | 62 tokens/s | 46 tokens/s | 1,110 tokens/s |
| **IQ3_S** | 52 tokens/s | 41 tokens/s | 1,070 tokens/s |
| **Coder** (IQ1_M) | 51 tokens/s | 44 tokens/s | 1,300 tokens/s |
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 389 | 539 | 571 | 561 | 543 | 496 |
| **IQ2_XS** | 332 | 463 | 495 | 486 | 472 | 437 |
| **IQ3_XXS** | 285 | 410 | 435 | 427 | 414 | - |
- **Writes answers** = how fast the reply appears (tokens per second).
- **Reads your prompt** = how fast it takes in what you send (long documents, code, chat history), measured on a
32K-token prompt; a 4K prompt reads at 740-1,000 tokens/s. A 32K prompt takes about 25 seconds with Q2_0.
### Output (tokens/s)
A card with more VRAM is faster, because more of the model fits on the GPU: an RTX 3090 (24 GB) should do roughly
100-140 tokens per second. All measurements, long-context numbers and estimates for other cards are in the
[details](docs/DETAILS.md#speed-measured).
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 88.7 | 94.6 | 87.5 | 76.4 | 65.1 | 56.3 |
| **IQ2_XS** | 82.0 | 78.0 | 65.3 | 63.7 | 52.0 | 48.0 |
| **IQ3_XXS** | 64.6 | 65.6 | 57.3 | 54.4 | 44.8 | - |
## Which model should I pick?
IQ3_XXS at 262K is not measured: with its 43 GB of experts, a 260K-token context brings a 64 GB PC to its memory
limit. Use up to 128K with IQ3_XXS on 64 GB.
**The size** (the same model, compressed more or less):
Time to first token is prompt length / prompt speed: about 7 s at 4K, 55 s at 32K, 4 minutes at 128K and 9 minutes at
262K. The raw numbers: `bench/results/`. The [paper](docs/paper/Strata-Paper.pdf) explains every number.
| Model | RAM+VRAM Requirements | Speed | Quality |
| --- | ---: | --- | --- |
| **Q2_0** | 37.6 GB | fastest | good |
| **IQ2_XS** | 39.2 GB | fast | better (**recommended**) |
| **IQ3_XXS** | 47.0 GB | slower | great |
| **IQ3_S** | 54.8 GB | slowest | best: matches the full model on the published tests (original model only) |
## Other GPUs (estimated)
**Will it fit?** Shard 1 is the part of the model that gets loaded when it starts: its experts go into your **RAM**,
the rest onto your graphics card (the second shard, a 29 GB lookup table, stays on the SSD). So it fits when your
**RAM is at least shard 1 + about 10 GB** for Windows and your other programs. With 64 GB of RAM every size fits
(IQ3_S with little else open); with 48 GB, Q2_0 and IQ2_XS. A bigger graphics card makes it faster, but it doesn't
lower the RAM needed.
Not measured - estimated from the runs above (same CPU and 64 GB RAM): the GPU part scaled by memory bandwidth, the CPU
part by how many more experts the card's VRAM holds. Treat as **±20%**. Numbers are *prompt / output* tokens/s.
**The version:**
| GPU | Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| RTX 5060 Ti 16GB | Q2_0 | ~341 / ~80 | ~472 / ~87 | ~501 / ~81 | ~492 / ~72 | ~476 / ~62 | ~435 / ~53 |
| | IQ2_XS | ~291 / ~80 | ~406 / ~77 | ~434 / ~63 | ~426 / ~62 | ~413 / ~51 | ~383 / ~47 |
| | IQ3_XXS | ~249 / ~66 | ~359 / ~65 | ~381 / ~56 | ~374 / ~54 | ~363 / ~45 | - |
| RTX 3090 24GB | Q2_0 | ~355 / ~128 | ~491 / ~140 | ~521 / ~130 | ~512 / ~115 | ~495 / ~100 | ~453 / ~85 |
| | IQ2_XS | ~303 / ~131 | ~422 / ~128 | ~451 / ~103 | ~444 / ~102 | ~430 / ~85 | ~398 / ~78 |
| | IQ3_XXS | ~260 / ~106 | ~374 / ~103 | ~396 / ~89 | ~390 / ~85 | ~378 / ~71 | - |
- **Qwen3.8-Flash-Next** - the original.
- **[Coder](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF)** - ISTA-DASLab's coding
version: half of the experts removed, keeping the ones that code, tool use and images need (91% of the full model's
SWE-bench Verified score, 99% of LiveCodeBench, by its authors). One size (IQ1_M: its experts stored like IQ3_S):
shard 1 is **29.6 GB**, so it fits a PC with **32 GB of RAM**, runs 262K context on 64 GB, and reads long prompts
the fastest of all. Weaker outside coding.
- **[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)** - a fine-tune by UkisAI
that thinks much shorter before answering, so you get the answer sooner, with about the same quality. Same speed per
token, and about the same RAM as the same size of the original (no IQ3_S). Its own license applies (see its page).
More VRAM matters more than a faster GPU: every extra GB holds ~700 more experts, and every expert on the GPU is one the
CPU does not have to compute. A 3090's 24 GB takes most of the CPU work away.
Not sure? Take **IQ2_XS** - or the **Coder** if you mainly write code, or have 32-48 GB of RAM. You can add another
one later with `START-HERE.bat --setup`.
## Which model?
## Install
All three are [ISTA-DASLab's GSQ-RCO quantizations](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
of [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next).
**You need:** an NVIDIA RTX 30, 40 or 50 card with 12 GB of VRAM or more, enough RAM for the size you pick (above),
~80 GB of free disk space (an SSD makes the first start much faster), and Windows 10/11 or Linux. The only thing you
install yourself is a current **NVIDIA driver** ([nvidia.com/drivers](https://www.nvidia.com/drivers) or the NVIDIA
App). Everything else - Python, the engine, the model - is set up for you.
| Model | Download | RAM it uses | Speed | Quality |
| --- | ---: | ---: | --- | --- |
| **Q2_0** | 66 GB | ~34 GB experts + ~6 GB | fastest | good |
| **IQ2_XS** | 68 GB | ~36 GB experts + ~6 GB | close to Q2_0 | a bit better |
| **IQ3_XXS** | 76 GB | ~43 GB experts + ~6 GB | slower (more CPU work) | best |
**Windows**
With 64 GB of RAM all three fit (close the browser for IQ3_XXS, and keep its context at 128K or less). With 48 GB only Q2_0 / IQ2_XS may fit. 32 GB is not enough.
1. [Download this project](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) and unzip it (or `git clone` it).
2. Double-click **`START-HERE.bat`**.
3. Answer a few questions - or just press Enter each time for the recommended choice:
- **Which model and size?** The original or Swift 1.5, and Q2_0, IQ2_XS, IQ3_XXS or IQ3_S - see [above](#which-model-should-i-pick)
- **How much context?** How much text it can keep in mind at once (it suggests one for your card)
- **Images?** Whether it should also read pictures
- **Experimental speed projection?** Off unless you say yes - [read what it does](docs/DETAILS.md#experimental-speed-projection-experimental-off-by-default) first
### Or: Swift 1.5 (a fine-tune that thinks shorter)
Then it downloads everything (the model is ~70 GB, so the first time takes a while - you can stop and it picks up
where it left off) and **starts the model**. Your browser opens the Strata app at `http://127.0.0.1:8080`.
The setup's first question also offers **[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)**,
UkisAI's fine-tune of Qwen3.8-Flash-Next, trained to reach the answer with much less thinking (its authors: 63% fewer
thinking tokens, 1.8x sooner answers, under 1% accuracy loss). Same architecture, the same three sizes, its own
vision encoder; Strata runs it at the same speed (4K, IQ2_XS: 465 prompt / 78.7 output tokens/s, vs 467 / 78.3 for
the original). Its authors recommend **IQ2_XS** (their Q2_0 is marked experimental). Its license is the Swift Open
License 1.0 - read it on the model page.
> **While the model starts, your PC can be slow or stop responding for 1-3 minutes** (longest the first time): Strata
> loads 35-55 GB into your RAM and locks part of it for the graphics card. That's normal - wait, and don't close the
> window. The window tells you what it is doing.
Our small check (8 reasoning questions, default thinking, IQ2_XS): both models got **8/8**; Swift used **1,234**
output tokens in 28 s, the original **2,682** in 46 s - most of the difference from one question the original
thought about for 1,524 tokens. Not a benchmark, but consistent with the claim.
**Next time**, just double-click `START-HERE.bat` again: it starts right away, nothing is downloaded twice. Close its
window to stop the model.
```
START-HERE.bat --setup --family swift --model IQ2_XS
```
**Updating:** download the new version and unzip it anywhere (or `git pull`), then run `START-HERE.bat` in it. The
model files are kept in a `Strata-data` folder next to your Strata folder, so a new copy finds them and sets itself up
the same way - nothing big is downloaded again.
## Before you start
You need **only an NVIDIA driver** (version 580 or newer; update it with the NVIDIA App or from
[nvidia.com/drivers](https://www.nvidia.com/drivers)). Everything else is installed for you the first time.
| | |
| --- | --- |
| GPU | NVIDIA **RTX 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070; RTX 30/40 are untested. |
| RAM | **64 GB** recommended (see the table above). |
| CPU | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. |
| Disk | ~70-80 GB free for the model, ~6 GB for the MTP layer (+1 GB with images). **Q2_0 on an AVX-512 CPU** also writes a one-time ~40 GB copy of its experts for the fast CPU kernel. An NVMe SSD is strongly recommended. |
| OS | Windows 10/11, or Linux (Ubuntu 22.04/24.04 get everything installed automatically). |
What the first start installs, all inside this folder (`.venv/`, `engine/`, `third_party/`, `models/`, `packs/`, `mtp/`):
Python 3.12 if you have none (for your user account, no admin), a private Python environment, NVIDIA's CUDA libraries
(from pip, ~0.4 GB), the ready-made Strata engine for RTX 30/40/50, the model and the MTP draft layer. If no
ready-made engine fits your PC, it offers to install the build tools (Visual Studio Build Tools + CUDA Toolkit on
Windows, `build-essential` + CUDA on Ubuntu) and compiles the engine for your GPU (asks first; 20-40 minutes once).
---
## Windows
### Double-click `START-HERE.bat`
**The first time** it asks four questions and does the rest:
1. **Which model?** Qwen3.8-Flash-Next (the original) or Swift 1.5 (the fine-tune that thinks shorter).
2. **Which size?** Q2_0, IQ2_XS or IQ3_XXS (it recommends one for your RAM).
3. **How much context?** 8K to 256K tokens (it recommends one for your VRAM).
4. **Images?** yes / no (see [Images](#images-vision)).
Then it downloads and prepares everything (the model is 66-76 GB, so the first start takes a while; an interrupted
download continues where it stopped) and **starts the model**: your browser opens `http://127.0.0.1:8080`, a small
page that shows it is running and lets you chat. The API is at `http://127.0.0.1:8080/v1` for your apps.
**Every time after that**, `START-HERE.bat` just starts the model (30-90 s to load 34-43 GB into RAM). Nothing is
downloaded again. Closing the window stops the model.
```
START-HERE.bat --setup install another model, or change context / images
START-HERE.bat --model IQ2_XS --context 32768 --vision yes --yes no questions
START-HERE.bat --gguf-dir D:\models\IQ2_XS use GGUF files you already have
START-HERE.bat --port 8081 another port
```
With more than one model installed, it asks which one to start. `run-<model>.bat` starts a model directly.
### Chat in the terminal (optional)
```
.venv\Scripts\python chat.py
```
---
## Linux
```bash
./setup.sh
```
The same questions, the same automatic install (it uses `sudo apt` for Python and, only if it has to compile,
for the build tools), and the same start: `http://127.0.0.1:8080`. Later runs of `./setup.sh` (or `./run-<model>.sh`)
start the model directly. Options as on Windows (`./setup.sh --setup`, `--model Q2_0 --yes`, `--gguf-dir /data/Q2_0`).
Terminal chat: `.venv/bin/python chat.py`.
---
**Linux:** run `./setup.sh` - same questions, same result.
## Using it
The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or edit the run script).
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>The Strata app's <b>Monitor</b> (left) while a coding agent writes the pagoda garden from the video (right)</sub></p>
| API | Endpoint |
| --- | --- |
| OpenAI Chat Completions (stream and non-stream, tools) | `POST /v1/chat/completions` |
| Anthropic Messages (stream and non-stream, tools) | `POST /v1/messages` |
| Model list / health | `GET /v1/models`, `GET /health` |
- **In the browser:** `http://127.0.0.1:8080` - the Strata app (it opens by itself when the model starts): **Chat**, a
live **Monitor** of the model and your GPU/CPU/RAM, and **About** with the settings and addresses.
- **Chat in the terminal:** `.venv\Scripts\python chat.py`
- **Your apps and coding agents:** add it as an "OpenAI-compatible" provider with base URL
**`http://127.0.0.1:8080/v1`**, any API key and any model name. Apps that use Anthropic's API: `http://127.0.0.1:8080/v1/messages`.
- **Thinking:** the model thinks before it answers. Choose **off, low, medium or high** - in the chat page menu, with
`/think low` in `chat.py`, or with your app's "reasoning effort" setting. Off is fastest; high is best for hard questions.
- **Pictures:** in the chat page click **Picture**; in `chat.py` type `/image <path>`; in apps just attach them.
- **From your phone or another PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key <secret>`, then open the
address the server window prints; see the [details](docs/DETAILS.md#using-it).
- **Experimental speed projection (off by default):** an experimental control vector that setup can turn on; it
changes how the model answers - read [what it does](docs/DETAILS.md#experimental-speed-projection-experimental-off-by-default) first.
```bash
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{
"model": "strata", "messages": [{"role": "user", "content": "Write a haiku about GPUs."}], "max_tokens": 512 }'
```
**Good to know:** it answers one request at a time. The first message of a chat is read in full (about 1 minute per
30,000 tokens); after that it keeps the conversation and reads only what is new, so follow-ups start in seconds.
```python
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none")
r = client.chat.completions.create(model="strata", messages=[{"role": "user", "content": "Hello!"}])
print(r.choices[0].message.content)
```
## Something went wrong?
- **Thinking levels: none, low, medium, high.** The model thinks before it answers (streamed as
`reasoning_content`, Anthropic: `thinking` blocks). Choose how much per request - in the chat page (the "Thinking"
menu), in `chat.py` (`/think low`), or over the API:
**My PC froze, or got very slow, the first time Strata started.**
That's normal while it starts, most of all the first time. Strata loads 35-55 GB into your RAM, locks part of it for
the graphics card, and works out how much of the model fits on your GPU. The mouse can freeze for a few minutes. **Wait, and don't close the
window.** The next starts are much faster. Still frozen after 10 minutes? Restart the PC, close other programs
(browsers use a lot of RAM) and try again. If it keeps happening, pick a smaller size (Q2_0 or IQ2_XS).
| API | how |
| --- | --- |
| OpenAI | `"reasoning_effort": "none" \| "low" \| "medium" \| "high"` (also `"reasoning": {"effort": ...}`, or `"chat_template_kwargs": {"enable_thinking": false}`) |
| Anthropic | `"output_config": {"effort": "low" \| "medium" \| "high"}`, `"thinking": {"type": "disabled"}`, or `"thinking": {"type": "enabled", "budget_tokens": N}` (under 2K = low, under 8K = medium, more = high) |
**It stopped while downloading or installing.**
Run `START-HERE.bat` again. It continues where it stopped.
Without a setting the model uses its own default, **high**. `none` answers at once (fastest); `low` keeps the thinking
short. The levels are instructions the model was trained with, not a hard token limit: on easy questions all three
think briefly, on hard ones `high` thinks longest and is most accurate.
- **Chat apps.** Any app with an "OpenAI-compatible" provider works: base URL `http://127.0.0.1:8080/v1`, any API key.
- **Context.** Chosen in setup (8K-262K). Requests longer than that are refused, never silently cut.
- **From other devices / the internet.** The server listens on your PC only (`127.0.0.1`). To reach it from elsewhere,
put a tunnel in front of it, for example [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/):
`cloudflared tunnel --url http://127.0.0.1:8080`. **Set a key first**, or anyone with the link can use your PC:
add `"api_key": "some-long-secret"` to `strata-<model>.json` (or set the `STRATA_API_KEY` environment variable);
clients then send it as their API key.
**It says the NVIDIA driver is too old.**
Update it (NVIDIA App or [nvidia.com/drivers](https://www.nvidia.com/drivers)), restart the PC, and run
`START-HERE.bat` again.
**Current limits (v1):** one request at a time; greedy decoding (temperature is ignored); every request processes its
whole prompt again (no conversation cache yet, so long chats have a long time-to-first-token); images only when set up
with them (below); no video.
**It says port 8080 is already in use.**
Strata is already running. Look for its window.
---
**It's very slow and the disk light keeps blinking.**
Your PC is out of free RAM. Close other programs, or pick a smaller size (Q2_0 or IQ2_XS).
## Images (vision)
**An answer stopped with "the engine stopped unexpectedly".**
Usually not enough RAM (on Linux the system then stops the engine). Just send your message again: Strata starts the
engine by itself. If it keeps happening, close other programs or pick a smaller size.
The model has a vision encoder: [`mmproj-Qwen3.8-Flash-Next-BF16.gguf`](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
(0.9 GB, a 27-layer ViT plus the projector into the language model). It is **optional**: say yes when the setup asks
"Images?", or run it again with `--vision gpu` (or `--vision cpu`). The setup downloads the encoder, builds a small
helper (`strata-vision`, from llama.cpp's `mtmd` library) and adds it to your start script. Nothing else changes.
**It says the prompt exceeds the context.**
The conversation is longer than the context you chose. Start a new chat, or run `START-HERE.bat --setup` and pick more
context.
| Encoder on | Time per picture | Cost |
| --- | --- | --- |
| **GPU** (recommended) | **0.1-0.5 s** (up to 1,024 image tokens) | ~1.4 GB of VRAM is kept free for it, so the expert cache is smaller: text output is a few % slower (table below) |
| CPU | 10-30 s (pictures are scaled down to ~300 image tokens) | nothing on the GPU |
**Still stuck?** Look in the [full troubleshooting table](docs/DETAILS.md#troubleshooting), or open an issue and
attach `strata-<model>.log` from the Strata folder.
A picture becomes up to 1,024 tokens of the context (a 640x480 photo: 300). The same picture sent again, as chat apps
do on every turn, is encoded only once.
## How does it work?
### Sending a picture
A model this big doesn't fit on a gaming graphics card. Strata splits the work between the parts of your PC:
**Terminal chat:** type `/image <path to a picture>`, press Enter, then type your question.
<p align="center"><img src="docs/paper/tiers.svg" width="700" alt="how Strata splits the model between GPU, RAM and SSD"></p>
```
you> /image C:\Users\me\Pictures\receipt.jpg
(picture attached: receipt.jpg - now type your question)
you> What is the total on this receipt?
```
- **The GPU** runs the part of the model that is used for every word, plus the "experts" it needs most often.
- **The RAM** holds all 24,576 experts, and **the CPU** computes the few the GPU doesn't have - at the same time as the GPU.
- **The SSD** holds a big lookup table; the model reads a few rows of it per word.
- **A small helper inside the model guesses the next words**, and Strata checks several guesses at once. That makes
it 1.6-1.8x faster than going word by word - and the answer is exactly the same.
**OpenAI API** (an `image_url` part: a `data:` URL, an `http(s)://` URL or a local file path):
The full story is in the [paper](docs/paper/Strata-Paper.pdf) and the [details](docs/DETAILS.md).
```python
import base64
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none")
img = base64.b64encode(open("photo.jpg", "rb").read()).decode()
r = client.chat.completions.create(model="strata", messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}},
{"type": "text", "text": "What is in this picture?"}]}])
print(r.choices[0].message.content)
```
## Credits
**Anthropic API:** an `image` block with a `base64` (or `url`) source, as usual.
- Model: [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team; compressed versions by
[ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF);
[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF) by UkisAI. Their licenses apply
to the model files.
- Built with parts of [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp) (MIT). Ideas from
[Splash](https://github.com/incoai/splash), [ninfer](https://github.com/Neroued/ninfer) and
[HyperQwen](https://github.com/syv-ai/HyperQwen). More in the [details](docs/DETAILS.md#credits-and-licenses).
JPEG, PNG, BMP and GIF work (WebP does not). Chat apps with image upload work the same way.
## License
### Speed with images on (4K context, measured)
| Model | Prompt tok/s, images off | Prompt tok/s, images on | Output tok/s, images off | Output tok/s, images on |
| --- | ---: | ---: | ---: | ---: |
| **Q2_0** | 541 | 531 | 90.7 | 86.9 |
| **IQ2_XS** | 468 | 458 | 77.1 | 74.0 |
| **IQ3_XXS** | 411 | 401 | 64.7 | 59.5 |
"Off" is the published setup; "on" runs with the encoder loaded on the GPU and its VRAM kept free, so ~1,000 fewer
experts fit in VRAM and the CPU computes a few more per token: 2-8% slower. For text, turning images on changes
nothing else (the output is bit-identical when the VRAM is the same).
A question about a picture (a 640x480 newspaper page = 300 image tokens, a 328-token prompt; the whole request, encoder
on the GPU, measured through the API):
| Model | Answer ("MEN WALK ON MOON") | Picture encoded | Prompt | Output |
| --- | ---: | ---: | ---: | ---: |
| **Q2_0** | 1.9 s | ~0.1 s | 207 tok/s | 65-73 tok/s |
| **IQ2_XS** | 2.3 s | ~0.1 s | 173 tok/s | 50-60 tok/s |
| **IQ3_XXS** | 2.7 s | ~0.1 s | 144 tok/s | 41-50 tok/s |
(Short prompts run below the 4K prompt speed: a 2,048-token chunk is where the prompt path is efficient. Short answers
run below the long-output speed: the first rounds have no draft yet.)
**How it works inside:** the encoder turns the picture into rows of the same width as the model's word embeddings;
Strata puts them where the prompt has `<|image_pad|>` tokens and gives each one its 2-D position (row and column in the
picture; the model uses interleaved M-RoPE). Answers match llama.cpp's multimodal implementation token for token on our
test images.
---
## Troubleshooting
| Symptom | What to do |
| --- | --- |
| `the NVIDIA driver is too old` | Update the driver (NVIDIA App or nvidia.com/drivers), restart, run `START-HERE.bat` again. |
| Python or the build tools could not be installed | Install what it names (links are printed), then run it again. Everything already done is kept. |
| `port 8080 is already in use` | Strata is already running (look for its window), or another program uses the port: `START-HERE.bat --port 8081`. |
| `cudaHostRegister ... out of memory` in the log | Normal on Windows: the engine pins the experts in per-layer slices instead. Only a problem if the load then fails. |
| The first start takes minutes | It is reading 34-43 GB into RAM; the second start is faster while the files are in the OS cache. |
| Slow output, disk light busy | Not enough free RAM: close other programs, or choose Q2_0 / IQ2_XS. |
| `prompt ... exceeds the context` | The request is longer than the context you chose: run setup again with a bigger `--context`. |
| Slower than the tables | The monitor plugged into the GPU and other GPU programs take VRAM from the expert cache; RAM running below its rated speed (enable EXPO/XMP in the BIOS) slows the CPU half. |
| `this server was started without the vision encoder` | The model was set up for text only: run setup again with `--vision gpu`. |
| A picture is refused or `cannot read the image` | Use JPEG, PNG, BMP or GIF (not WebP). |
| Pictures are slow (10-30 s) | The encoder runs on the CPU: run setup again with `--vision gpu` (needs ~1.4 GB of VRAM). |
| Anything else | The engine log is `strata-<model>.log` in this folder. |
---
## How it works
<p align="center"><img src="docs/paper/tiers.svg" width="760" alt="memory tiers"></p>
- **GPU (VRAM):** attention and DeltaNet mixers, the gated-residual weights, routers, shared experts, output head, the MTP
draft layer, the KV cache, and an **expert cache** that fills the rest of VRAM with the most-used experts (it adapts to
the conversation while you chat).
- **RAM:** all 24,576 experts, pinned. The CPU computes the experts that are not on the GPU **in place**, at the same time
as the GPU works on the cached ones (AVX-512 / AVX2 kernels, ggml's for the i-quants).
- **SSD:** the 28.8 GB n-gram table, read a few rows per token through the OS cache.
- **Speculation:** the model's own MTP layer drafts up to 3 tokens; one pass over all 48 layers checks them. 2.4-3.2
tokens per pass on average.
- **Prompts** are processed in 2,048-token chunks with the experts streamed to the GPU over PCIe.
The full story, with measurements, bottlenecks and what comes next: **[docs/paper/Strata-Paper.pdf](docs/paper/Strata-Paper.pdf)**.
---
## Credits and licenses
- Model: [Qwen/Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team; quantizations:
[ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF).
Swift 1.5: [ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
by UkisAI. Their licenses apply to the weights.
- [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp) (MIT): the i-quant formats, the GPU dot products and
dequantizers transcribed in `src/kernels/cuda/iq_kernels.cu`, the CPU backend linked for the i-quant experts, the
`mtmd` library behind the image encoder (`tools/vision/`), and `gguf-py` used by the tools. See
`third_party/ggml/LICENSE`.
- Ideas from [Splash](https://github.com/incoai/splash), [ninfer](https://github.com/Neroued/ninfer) and
[HyperQwen](https://github.com/syv-ai/HyperQwen); references in the paper.
Strata is open source under the [MIT License](LICENSE). A few parts carry their own licenses: `third_party/ggml`
(MIT, llama.cpp / ggml), the web app's font (SIL Open Font License 1.1) and the experimental speed projection's
vector in `data/experimental-speed-projection` (Qwen Community License 1.0, from the model's activations). The
models are not part of this repository; each model's own license applies to its files.
@@ -0,0 +1,29 @@
# 2026-09-27: expert cache on vs off (issue #23)
Question: the engine used to warn at start that "the GPU hit path is NOT CORRECT ... any OUTPUT from it is not". Does
the expert cache make the output worse?
Method: teacher-forced. `strata --dump-logits` runs a text one token at a time and writes the logits at every
position, so both runs see the same text and each position's next-token prediction can be compared:
- cache off: no `--expert-cache`, every routed expert computed by the CPU pool;
- cache on: `--expert-profile pack/profile-decode-8k.bin --expert-cache auto`, 5,130 of the most-used experts on the GPU.
Q2_0, RTX 5070, the first 512 or 1,024 tokens of three frozen prompts.
| Text | Same top-1 | Median KL | Perplexity off | Perplexity on | On - off (nats per token) |
|---|---:|---:|---:|---:|---:|
| Code agent (512) | 97.7% | 3.0e-04 | 2.672 | 2.630 | -0.016 +- 0.019 |
| Long document (1,024) | 96.2% | 4.5e-03 | 29.844 | 29.843 | -0.000 +- 0.005 |
| Chat (1,024) | 95.0% | 4.4e-03 | 18.380 | 18.301 | -0.004 +- 0.006 |
| **All 2,557 tokens** | | | | | **-0.005 +- 0.005** |
Reading:
- The cache changes which of two nearly equal candidates wins at 2-5% of positions. Most of the flips have a top-2
margin under 0.5 logits (`logits-compare.txt`).
- Neither side is more accurate: the perplexity of the actual text is the same, within 1 standard error.
- A GPU expert and the CPU compute the same quantized expert with a different floating-point order. Both are an
equally good approximation of the model.
The start-up warning dates from round 328, when the GPU hit path had a real fault (divergence from token 0). That fault
was fixed long ago and is covered by `expert_parity`, `native_expert_parity` and the grouped kernels' tests. Since
engine 0.1.8 the warning is replaced by one accurate line.
+36
View File
@@ -0,0 +1,36 @@
# 2026-09-27: the experimental-speed-projection control vector
The package's `Qwen3.8-Flash-Next-experimental-speed-projection.gguf` (47 unit directions of 2,560, one per layer),
applied as `--control-vector-scaled FILE:1.0 --control-vector-layer-range 4 44 --cvec-mode project --cvec-dir
per-layer`: after layers 4-44, every hyper-connection stream `h -= (h . v) v`. The package describes it as a
refusal-direction projection. Q2_0, RTX 5070, fixed experts (`--expert-cache 4000 --pcie-frac 0 --adapt-swaps 0`).
## Kernel (`cvec_parity`)
Project and add against a double-precision reference (max error 5e-7); with the switch off, the kernel's pending
residual write is bitwise the fused hyper-connection read's own, so a loaded-but-off vector is the stock engine.
## Teacher-forced, stock vs projection (`esp_kl.py`, per-token path)
| Text | Tokens | Same top-1 | Mean KL | p99 KL | Perplexity stock | With the vector |
|---|---:|---:|---:|---:|---:|---:|
| Code | 511 | 92.8% | 0.098 | 1.69 | 2.594 | 2.982 |
| Document | 1,023 | 89.8% | 0.058 | 0.84 | 30.299 | 30.984 |
| Chat | 1,023 | 89.6% | 0.050 | 0.33 | 18.409 | 18.485 |
| **All** | 2,557 | | **0.063** (max 4.10) | 1.00 | | |
The package reports a mean KL of 0.186 on its own neutral text with UD-Q2_K_XL in llama.cpp.
Cost on the same tokens: 44.34 -> 44.26, 46.74 -> 46.56, 47.02 -> 46.83 tok/s (0.2-0.4%).
## Serve (`esp_serve.py`)
- Loaded and switched off (`cvec=0`) vs an engine without it: 65 / 65 first tokens after prefixes (verify windows
and the batched prompt path) and three 256-token generations **token-identical**.
- On, off, on again: identical (the switch drops the conversation cache, so nothing computed one way is reused the
other way).
- The prompt paths steer like the per-token path: served-with-vector first tokens agree with the per-token path's
teacher-forced argmax with the vector 62 / 65, and with the stock one 57 / 65 (stock served: 64 / 65 and 57 / 65).
- Decode tok/s of 3 x 256-token generations: stock 66.4, on 76.7 / 81.2. **Not a like-for-like comparison:** the
model writes different text with it on (on the code prompt it copied a long stretch of the source, which prompt
lookup drafts at 91% acceptance, where stock stopped after 78 tokens). On the same tokens it is 0.2-0.4% slower.
+85
View File
@@ -0,0 +1,85 @@
"""Teacher-forced logits, stock vs the experimental-speed-projection vector, on the same texts and fixed experts.
python esp_kl.py run # the dumps (per-token path)
python esp_kl.py compare # same top-1, mean/median KL(stock || projection), perplexities
"""
import json
import os
import subprocess
import sys
import numpy as np
S = os.path.dirname(os.path.abspath(__file__))
E = r"C:\Users\AI-Server\Desktop\Strata\Public\Engine"
EXE = E + r"\build-dev\strata.exe"
VEC = r"C:\Users\AI-Server\Desktop\Strata\experimental-speed-projection\Qwen3.8-Flash-Next-experimental-speed-projection.gguf"
TEXTS = {"code": S + r"\p512.ids", "doc": S + r"\4k-02-long-doc-1k.ids", "chat": S + r"\4k-04-chat-1k.ids"}
OUT = S + r"\esp-kl"
ARMS = {"stock": [],
"proj": ["--control-vector-scaled", VEC + ":1.0", "--control-vector-layer-range", "4", "44",
"--cvec-mode", "project", "--cvec-dir", "per-layer"]}
def base_args():
c = json.load(open(S + r"\cfg-rel.json"))
a = list(c["args"])
i = a.index("--expert-cache")
a[i + 1] = "4000"
j = a.index("--prefill") # the prompt one token at a time: a logits row per position
del a[j:j + 2]
return a + ["--pcie-frac", "0", "--adapt-swaps", "0"], c
def run():
os.makedirs(OUT, exist_ok=True)
args, c = base_args()
env = dict(os.environ)
env["PATH"] = os.pathsep.join(c["lib_dirs"]) + os.pathsep + env["PATH"]
for name, path in TEXTS.items():
for arm, extra in ARMS.items():
out = f"{OUT}\\{name}-{arm}.bin"
if os.path.exists(out):
continue
cmd = [EXE, *args, *extra, "--tokens-file", path, "--max-new", "1", "--dump-logits", out]
with open(f"{OUT}\\{name}-{arm}.log", "w") as log:
r = subprocess.run(cmd, cwd=c["cwd"], stdout=log, stderr=subprocess.STDOUT, env=env)
print(name, arm, "exit", r.returncode, flush=True)
def load(p):
h = np.fromfile(p, dtype=np.int32, count=2)
return np.memmap(p, dtype=np.float32, mode="r", offset=8, shape=(int(h[1]), int(h[0])))
def lsm(x):
x = x.astype(np.float64)
m = x.max(axis=-1, keepdims=True)
return x - (m + np.log(np.exp(x - m).sum(axis=-1, keepdims=True)))
def compare():
print(f"{'text':<6}{'tokens':>7}{'same top-1':>11}{'mean KL':>9}{'median KL':>11}{'p99 KL':>8}{'ppl stock':>10}{'ppl proj':>9}")
allkl = []
for name, path in TEXTS.items():
ids = [int(x) for x in open(path).read().replace(",", " ").split()]
a, b = load(f"{OUT}\\{name}-stock.bin"), load(f"{OUT}\\{name}-proj.bin")
n = min(a.shape[0], b.shape[0], len(ids) - 1)
kl, same, la, lb = [], 0, [], []
for r in range(0, n, 64):
pa, pb = lsm(a[r:min(n, r + 64)]), lsm(b[r:min(n, r + 64)])
kl += list((np.exp(pa) * (pa - pb)).sum(axis=1))
same += int((pa.argmax(1) == pb.argmax(1)).sum())
nxt = np.array(ids[r + 1:min(n, r + 64) + 1])
la += list(pa[np.arange(len(nxt)), nxt])
lb += list(pb[np.arange(len(nxt)), nxt])
kl = np.array(kl)
allkl += list(kl)
print(f"{name:<6}{n:>7}{100 * same / n:>10.1f}%{kl.mean():>9.3f}{np.median(kl):>11.4f}{np.percentile(kl, 99):>8.2f}"
f"{np.exp(-np.mean(la)):>10.3f}{np.exp(-np.mean(lb)):>9.3f}")
allkl = np.array(allkl)
print(f"all {len(allkl)} tokens: mean KL {allkl.mean():.3f}, max {allkl.max():.2f}, p99 {np.percentile(allkl, 99):.2f}")
if __name__ == "__main__":
{"run": run, "compare": compare}[sys.argv[1]]()
+129
View File
@@ -0,0 +1,129 @@
"""Serve-mode checks of the experimental-speed-projection vector (exact mode: fixed experts, no PCIe share).
python esp_serve.py run ARM # ARM = stock | vec; writes esp-serve/<arm>.json
python esp_serve.py compare
Per arm: the first greedy token after many prefixes of three texts (short ones go through the verify windows, long
ones through the batched prompt path; the conversation cache reuses the earlier prefix), and three 256-token greedy
generations. The vec arm runs everything with cvec=1, then cvec=0, then cvec=1 again.
"""
import json
import os
import sys
import threading
S = os.path.dirname(os.path.abspath(__file__))
E = r"C:\Users\AI-Server\Desktop\Strata\Public\Engine"
sys.path.insert(0, E)
from serve.server import StrataEngine # noqa: E402
EXE = E + r"\build-dev\strata.exe"
VEC = r"C:\Users\AI-Server\Desktop\Strata\experimental-speed-projection\Qwen3.8-Flash-Next-experimental-speed-projection.gguf"
TEXTS = {"code": S + r"\p512.ids", "doc": S + r"\4k-02-long-doc-1k.ids", "chat": S + r"\4k-04-chat-1k.ids"}
OUT = S + r"\esp-serve"
VEC_ARGS = ["--control-vector-scaled", VEC + ":1.0", "--control-vector-layer-range", "4", "44",
"--cvec-mode", "project", "--cvec-dir", "per-layer"]
def ids_of(name):
return [int(x) for x in open(TEXTS[name]).read().replace(",", " ").split()]
def engine(extra):
c = json.load(open(S + r"\cfg-rel.json"))
a = list(c["args"])
a[a.index("--expert-cache") + 1] = "4000"
env = dict(os.environ)
env["PATH"] = os.pathsep.join(c["lib_dirs"]) + os.pathsep + env["PATH"]
return StrataEngine(EXE, a + ["--pcie-frac", "0", "--adapt-swaps", "0"] + extra, cwd=c["cwd"],
log=OUT + r"\engine.log", env=env)
def gen(eng, ids, n, sampling):
toks = [t for t in eng.generate(ids, n, sampling, threading.Event()) if t is not None]
return toks, dict(eng.last)
def suite(eng, sampling):
out = {"first": {}, "long": {}}
for name in TEXTS:
ids = ids_of(name)
# short prefixes growing by 4 (each continues the live session through the verify windows), then long ones
# SHRINKING (no cached prefix, so each is read from 0 by the batched prompt path)
lens = list(range(32, 72, 4)) + list(range(min(len(ids) - 1, 700), 95, -48))
out["first"][name] = {L: gen(eng, ids[:L], 1, sampling)[0][0] for L in lens}
for name in TEXTS:
toks, last = gen(eng, ids_of(name)[:300], 256, sampling)
out["long"][name] = {"tokens": toks, "last": last}
return out
def run(arm):
os.makedirs(OUT, exist_ok=True)
eng = engine(VEC_ARGS if arm == "vec" else [])
res = {"info": eng.info}
if arm == "stock":
res["stock"] = suite(eng, {})
else:
res["on"] = suite(eng, {"experimental_speed_projection": True})
res["off"] = suite(eng, {"experimental_speed_projection": False})
res["on2"] = suite(eng, {"experimental_speed_projection": True})
eng.close()
json.dump(res, open(f"{OUT}\\{arm}.json", "w"))
print(arm, "done", eng.info, flush=True)
def compare():
import numpy as np
sys.path.insert(0, S)
from esp_kl import load, OUT as KL
st = json.load(open(OUT + r"\stock.json"))
ve = json.load(open(OUT + r"\vec.json"))
print("engine INFO (vec):", ve["info"])
def eq_first(a, b):
tot = same = 0
for name in a:
for L, t in a[name].items():
tot += 1
same += int(b[name][L] == t)
return same, tot
def eq_long(a, b):
return all(a[n]["tokens"] == b[n]["tokens"] for n in a), \
[(n, next((i for i, (x, y) in enumerate(zip(a[n]["tokens"], b[n]["tokens"])) if x != y), None)) for n in a]
print("1. vector loaded, switched off == no vector")
print(" first tokens: %d / %d identical" % eq_first(st["stock"]["first"], ve["off"]["first"]))
print(" 256-token generations identical:", *eq_long(st["stock"]["long"], ve["off"]["long"]))
print("2. repeatable: on, off, on again")
print(" first tokens: %d / %d identical" % eq_first(ve["on"]["first"], ve["on2"]["first"]))
print(" 256-token generations identical:", *eq_long(ve["on"]["long"], ve["on2"]["long"]))
print("3. the prompt paths against the per-token path's teacher-forced argmax (row L-1 predicts token L)")
for arm, first in (("stock", st["stock"]["first"]), ("on", ve["on"]["first"])):
for ref in ("stock", "proj"):
agree = tot = 0
for name in first:
d = load(f"{KL}\\{name}-{ref}.bin")
for L, t in first[name].items():
L = int(L)
if L - 1 < d.shape[0]:
tot += 1
agree += int(int(np.argmax(d[L - 1])) == t)
print(f" served {arm:<5} vs per-token {ref:<5}: {agree} / {tot}")
print("4. how different the text is: 256-token generations, on vs stock, first difference")
print(" ", eq_long(st["stock"]["long"], ve["on"]["long"])[1])
print("5. decode speed (exact mode: fixed experts, no PCIe share - not the normal setup)")
for label, r in (("stock", st["stock"]), ("vec off", ve["off"]), ("vec on", ve["on"]), ("vec on 2", ve["on2"])):
g = sum(v["last"]["generated"] for v in r["long"].values())
ms = sum(v["last"]["decode_ms"] for v in r["long"].values())
acc = sum(v["last"].get("drafts_accepted", 0) for v in r["long"].values())
off = sum(v["last"].get("drafts_offered", 0) for v in r["long"].values())
print(f" {label:<9} {g / ms * 1000:6.1f} tok/s drafts accepted {acc}/{off}")
if __name__ == "__main__":
if sys.argv[1] == "run":
run(sys.argv[2])
else:
compare()
@@ -0,0 +1,20 @@
1. vector loaded, switched off == no vector
first tokens: 65 / 65 identical
256-token generations identical: True [('code', None), ('doc', None), ('chat', None)]
2. repeatable: on, off, on again
first tokens: 65 / 65 identical
256-token generations identical: True [('code', None), ('doc', None), ('chat', None)]
3. the prompt paths against the per-token path's teacher-forced argmax (row L-1 predicts token L)
served stock vs per-token stock: 64 / 65
served stock vs per-token proj : 57 / 65
served on vs per-token stock: 57 / 65
served on vs per-token proj : 62 / 65
4. how different the text is: 256-token generations, on vs stock, first difference
[('code', 11), ('doc', 6), ('chat', 3)]
5. decode speed (exact mode: fixed experts, no PCIe share - not the normal setup)
stock 66.4 tok/s drafts accepted 256/309
vec off 65.7 tok/s drafts accepted 251/318
vec on 76.7 tok/s drafts accepted 366/425
vec on 2 81.2 tok/s drafts accepted 366/423
[exited with code 0]
@@ -0,0 +1,6 @@
text tokens same top-1 mean KL median KL p99 KL ppl stock ppl proj
code 511 92.8% 0.098 0.0031 1.69 2.594 2.982
doc 1023 89.8% 0.058 0.0271 0.84 30.299 30.984
chat 1023 89.6% 0.050 0.0265 0.33 18.409 18.485
all 2557 tokens: mean KL 0.063, max 4.10, p99 1.00
+60
View File
@@ -0,0 +1,60 @@
# 2026-09-27: Q4_0 KV with Hadamard rotation (PR #21, `--kv q4_0`)
PR #21 (code-martin) adds a 4-bit K/V format: an orthonormal 256-point Walsh-Hadamard rotation of K, V and Q, then
ggml `q4_0` blocks (576 B per cell and layer, vs 1,056 B in int8).
## Integration fixes made on merge
- **Q4 + KV streaming (`--kv-resident`).** The PR had no host copy for Q4 and skipped the staging, so with streaming
on, the prompt path wrote K/V through the residency map's -1 entries (out-of-bounds writes).
- Q4 is now a third format in `kv_stream`: host copy, block residency, prompt-path staging, MTP ring and STATE_HASH.
- `kv_stream_parity` checks it bitwise, streamed vs resident, with ring restore.
- **Duplicate attention.** The per-token decode path ran attention twice, the first time before the selection's
blocks were made resident; it now runs once.
- **Tie-break.** The group maximum's tie-break is now deterministic across lanes: equal |x| with opposite signs
could give one block two scales.
- **Unrelated changes dropped:** duplicate path fixes (already in 0.1.7), personal `.gitignore` entries, and a
re-encoded CMakeLists.
`kv_q4_parity` (the PR's test) and all 70 ctest tests pass.
## Quality (teacher-forced, `tools/kv_precision_compare.py`)
Same text, same fixed expert set (Q2_0, 3,000 slots, `--adapt-swaps 0`); only `--kv` differs. fp16 is the reference.
| Text | KV | Same top-1 as fp16 | Mean KL | Perplexity | vs fp16 (nats/token) |
|---|---|---:|---:|---:|---:|
| code agent (512) | int8 | 98.4% | 0.014 | 2.649 | -0.011 +- 0.006 |
| | q4_0 | 95.7% | 0.030 | 2.598 | -0.031 +- 0.014 |
| document (1,024) | int8 | 95.6% | 0.009 | 30.18 | +0.012 +- 0.005 |
| | q4_0 | 91.3% | 0.039 | 32.26 | **+0.078 +- 0.011** |
| chat (1,024) | int8 | 95.5% | 0.009 | 18.24 | -0.003 +- 0.005 |
| | q4_0 | 90.7% | 0.028 | 18.52 | +0.012 +- 0.008 |
| document, 8K (every 8th position) | int8 | 92.3% | 0.114 | 7.63 | -0.013 +- 0.020 |
| | q4_0 | 88.0% | 0.297 | 8.65 | **+0.112 +- 0.037** |
- **int8 is indistinguishable from fp16** (within about 2 standard errors everywhere).
- **q4_0 costs measurable precision, and more as the context grows:**
- document perplexity +8% at 1K and +12% at 8K;
- the KL from fp16 is 2.6-4.2x int8's;
- code and chat are near-neutral.
- **Needles with `--kv q4_0 --kv-resident 32768`: 5/5** (1K, 32K, 128K at 10% and 90% depth, 262K;
`needles-q4.*`). Retrieval holds.
## Speed and memory (Q2_0, 128K, matrix bench, `--kv-resident 32768`)
| KV | Expert slots | Decode tok/s | Tokens/round | Rounds/s | K/V in RAM |
|---|---:|---:|---:|---:|---:|
| int8 (`kv-stream/matrix-stream`) | 4,044 | 62.4 | 2.52 | 24.8 | 1.52 GiB |
| q4_0 (`matrix-q4`) | 4,190 | 67.4 | 2.62 | 25.7 (+3.7%) | 0.83 GiB |
Without streaming (contexts up to 64K, or too little RAM for it) q4_0 halves the whole KV cache in VRAM instead.
## Decision
`--kv q4_0` is a user option:
- `START-HERE.bat --kv q4_0`, or the question setup asks above 8K context;
- or `--kv q4_0` in a `strata-*.json`.
int8 stays the default: every published quality number was measured with it, and q4_0's precision cost is real,
though it passes the needles.
+44
View File
@@ -0,0 +1,44 @@
# The GSQ-RCO Coder (256 of 512 experts) on Strata
RTX 5070 12 GB, Ryzen 5 7600, 64 GB DDR5-5200, Windows 10. Engine 0.1.15 + PR #54 (the expert count from the model
file), the settings setup writes (`--prefill auto`, 8-bit KV above 4K, KV streaming from 64K, MTP, greedy, 256 tokens),
`data/expert-profile-coder.bin`, the same code-agent prompts as `2026-09-28-speed-0114`.
| | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Prompt, tokens/s | 599 | 1,152 | 1,298 | 1,350 | 1,266 | 1,034 |
| Output, tokens/s | 53.3 | 50.6 | 53.3 | 50.8 | 44.0 | 42.8 |
| Draft acceptance | 0.720 | 0.707 | 0.785 | 0.696 | 0.619 | 0.684 |
| Experts in VRAM | 2,570 | 2,615 | 2,418 | 2,382 | 2,326 | 2,208 |
Every run in [`matrix.json`](matrix.json). The 128K and 262K answers were read: coherent (they list and discuss the
files of the prompt).
## Why it is fast
The kept experts are stored like IQ3_S's (IQ2_S / IQ3_XXS / IQ3_S / IQ4_XS gate and up, IQ4_NL / Q2_0 down; "IQ1_M" is
the release's name for 1.89 bits per *original* parameter), so a token costs about what IQ3_S costs where the expert is
on the CPU. But there are half as many experts: the 23 GB of them pin completely (`cudaHostRegister` of the whole
arena), a 12 GB card holds ~20% of them instead of ~10%, and the prompt path streams half as many.
## The expert profile
`data/expert-profile-coder.bin` (48 x 256, all 12,288 pairs ranked): the shipped 48 x 512 ranking, each pair mapped to
its kept expert's new index through section 2 of the release's `tensor-allocation/*.rco-allocation.txt` (the kept
experts in their original order; gate, up and down agree on which were kept), pruned pairs dropped. 5,492 of the
shipped top 8,000 survive (as PR #54 found). With 2,537 slots on the 12 GB card, 72% of the expert reads of a 4K code
prompt hit the GPU - a wrong index mapping would give about the slot share, 21%.
## Images
The Coder ships the original's vision encoder (the same file). With `--vision` on the GPU it described a Strata
Monitor screenshot correctly, windows, labels and the numbers of the dashboard and the requests table (1,079 prompt
tokens with the image, 2,012 generated at ~50 tokens/s).
## The experimental speed projection
The vector was made from the full model's activations; the Coder has the same residual stream. On the Coder it loads
(41 layers steered), costs no measurable speed (47.7-55.7 tokens/s with it on or off), and both settings write correct
answers to three coding prompts. What it is for - fewer refusals - was not measured. (Two runs of the same prompt with
it off also differ after ~130-200 characters in serve mode, so this check cannot show how far it moves ordinary text;
the teacher-forced method of `2026-09-27-esp` needs the token-by-token prompt path, which native packs do not run.)
@@ -0,0 +1,86 @@
[
{
"model": "CODER",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 18.0,
"prefill_tok_s": 599.05,
"ttft_s": 2.54,
"decode_tok_s": 53.33,
"decoded": 256,
"spec_accept": 0.72,
"tokens_per_round": 2.86,
"vram_slots": 2570
},
{
"model": "CODER",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 19.6,
"prefill_tok_s": 1152.17,
"ttft_s": 3.94,
"decode_tok_s": 50.6,
"decoded": 256,
"spec_accept": 0.707,
"tokens_per_round": 2.88,
"vram_slots": 2615
},
{
"model": "CODER",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 40.7,
"prefill_tok_s": 1297.8,
"ttft_s": 25.17,
"decode_tok_s": 53.34,
"decoded": 256,
"spec_accept": 0.785,
"tokens_per_round": 3.02,
"vram_slots": 2418
},
{
"model": "CODER",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 75.5,
"prefill_tok_s": 1350.09,
"ttft_s": 49.01,
"decode_tok_s": 50.84,
"decoded": 256,
"spec_accept": 0.696,
"tokens_per_round": 2.61,
"vram_slots": 2382
},
{
"model": "CODER",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 121.5,
"prefill_tok_s": 1266.31,
"ttft_s": 102.95,
"decode_tok_s": 44.03,
"decoded": 256,
"spec_accept": 0.619,
"tokens_per_round": 2.33,
"vram_slots": 2326
},
{
"model": "CODER",
"tier": "262k",
"prompt_tokens": 259943,
"exit": 0,
"wall_s": 280.7,
"prefill_tok_s": 1033.91,
"ttft_s": 252.92,
"decode_tok_s": 42.8,
"decoded": 256,
"spec_accept": 0.684,
"tokens_per_round": 2.6,
"vram_slots": 2208
}
]
@@ -0,0 +1,47 @@
# Faster prompt reading (engine 0.1.13)
RTX 5070 12 GB, Ryzen 5 7600, 64 GB DDR5. 32K-token prompt (`bench/prompts/long/32k-01-code-agent.ids`), one-shot
engine, `--expert-cache auto`, greedy. Numbers are prompt tokens/s.
## What changed, step by step
| Step | What | Result |
|---|---|---|
| Buffers | The attention half and the MoE half of a layer share one scratch region (~260 KB less per prompt token) | bit-identical; chunk 4096 borrows 1.78 GiB instead of 2.84 |
| PLE rows | The next chunk's PLE rows are read from the model file on a thread while the GPU works | bit-identical; 762 -> 790 at chunk 4096 |
| Chunk size | `--prefill auto`: the largest of 8192 / 6144 / 4096 / ... whose buffers fit in the cache slots it may borrow; a request borrows only what its own prompt needs | Q2_0: 4096 791, 6144 878, 8192 973 |
| Host copies | Experts the arena could not pin are copied to pinned buffers by helper threads instead of the launching thread | bit-identical; IQ3_S at 4096: 551 -> 652 |
| PLE block | Computed for the whole chunk (two GEMMs) instead of token by token | Q2_0 981 -> 1053, IQ3_S 831 -> 940 |
| Experts | llama.cpp's MMQ kernels (weights stay quantized, int8 tensor cores) instead of dequantize-to-FP16 + cuBLAS | Q2_0 1052 -> 1130, IQ3_S (8192) 932 -> 1014 |
| Streaming | At chunks of 2048+ every non-resident expert streams in a fixed order through a ring, so the next layer's arrive while the current layer's attention runs | bit-identical; Q2_0 1130 -> 1290, IQ3_S -> 1208 |
Tried and dropped: cuBLAS grouped GEMMs for the experts (faster in isolation, slower inside the engine: 770 vs 790).
## Before and after
| | engine 0.1.12 (`--prefill 2048`) | engine 0.1.13 (`--prefill auto`) |
|---|---:|---:|
| Q2_0, 32K prompt | 572 | 1,290 |
| IQ3_S, 32K prompt | 383 | 1,208 |
| Server, Q2_0, 128K context, 999-token prompt | 353 | 438 |
| Server, 6,927-token prompt | 529 | 1,077 |
| Server, 28,584-token prompt | 584 | 1,249 |
Output speed is unchanged (the decode path is the same). Short prompts (a few hundred tokens) go through the verify
windows as before.
## Quality
- "Bit-identical" steps were checked with the GDN state hash after the 32K prompt (all 36 recurrent layers).
- The steps that change rounding (the batched PLE block, MMQ's 8-bit activations) were compared teacher-forced: the
32K prompt is read by the prompt path up to its last 256 tokens, those go through the token path with their logits
dumped. At 32K every rounding change moves the predictions about this much - even a different chunk size of the
unchanged engine - so that is the yardstick:
| Against the 8192-chunk FP16 path | Same top-1 | Mean KL | Perplexity (reference 9.21) |
|---|---:|---:|---:|
| Chunk 6144, same code (the yardstick) | 89.8% | 0.33 | 9.18 |
| MMQ experts | 85.9% | 0.38 | 8.63 |
- Needles (the answer hidden at a given depth): 5 of 5 at 1K, 32K, 128K (two depths) and 262K, Q2_0, KV streaming.
- A rerun of the same configuration is bit-identical.
@@ -0,0 +1,32 @@
# Speed with engine 0.1.14
RTX 5070 12 GB (PCIe 5.0 x16), Ryzen 5 7600, 64 GB DDR5-5200, Windows 10. The ready-made 0.1.14 engine, one-shot runs
with what setup writes: `--prefill auto`, the shipped expert profile, `--expert-cache auto`, 8-bit KV above 4K, KV
streaming (`--kv-resident 32768`) from 64K, MTP (`--spec 4 --spec-min-p 0.5`), greedy, 256 generated tokens. One
code-agent prompt per length (`bench/prompts/*/…-code-agent.ids`, the same prompts as `2026-09-24-final`).
The tables: [`matrix.md`](matrix.md) (prompt and output speed, draft acceptance, experts in VRAM); every run in
[`matrix.json`](matrix.json).
- **IQ2_XS** was measured with Swift 1.5's IQ2_XS (the original's file was not on the machine; the two run at the same
speed: 4K 465 vs 467 prompt tokens/s in `2026-09-24`).
- **IQ3_S at 128K** comes from a first pass during which a large download was running (the clean rerun of that row
was stopped when Windows ran short of memory); the other rows are from runs on an otherwise idle machine.
- **IQ3_XXS and IQ3_S at 262K** are not measured: with 43 / 50 GB of experts, a 260K context exceeds 64 GB of RAM.
## Against 0.1.12
Prompts are 1.2x (1K) to 2.4x (32K-128K) faster: the 0.1.13 prompt path (chunks of up to 8,192 tokens, MMQ experts,
streamed expert copies; see `2026-09-28-prefill-speed`).
Output speed moves with the text: speculative decoding is faster when more drafts are accepted, and the new prompt
path's rounding changes the answer, so the same prompt can land a few percent either way (Q2_0 32K: acceptance 0.865
then, 0.709 now). The engine itself is not slower: on the 4K prompt, run back to back on the same machine state,
0.1.14 writes 88.5 tokens/s and 0.1.12 85.7 (3 runs each, same free VRAM).
## Found on the way
IQ3_XXS at 64K and 128K ended with `out of memory: cudaFuncSetAttribute` in the MMQ launch: CUDA loads a kernel's code
when it is first used, and mid-prompt there was no VRAM left for it. Loading every kernel at start
(`CUDA_MODULE_LOADING=EAGER`, which the engine now sets itself) fixes it for ~30 MB of VRAM; the IQ3_XXS 64K/128K rows
were measured with that fix.
@@ -0,0 +1,322 @@
[
{
"model": "Q2_0",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 41.0,
"prefill_tok_s": 494.43,
"ttft_s": 3.55,
"decode_tok_s": 84.26,
"decoded": 256,
"spec_accept": 0.709,
"tokens_per_round": 2.72,
"vram_slots": 4176
},
{
"model": "Q2_0",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 41.2,
"prefill_tok_s": 1006.63,
"ttft_s": 5.05,
"decode_tok_s": 90.26,
"decoded": 256,
"spec_accept": 0.846,
"tokens_per_round": 3.23,
"vram_slots": 4123
},
{
"model": "Q2_0",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 63.1,
"prefill_tok_s": 1307.6,
"ttft_s": 25.63,
"decode_tok_s": 73.61,
"decoded": 256,
"spec_accept": 0.709,
"tokens_per_round": 2.68,
"vram_slots": 3833
},
{
"model": "Q2_0",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 89.0,
"prefill_tok_s": 1293.5,
"ttft_s": 51.12,
"decode_tok_s": 69.02,
"decoded": 256,
"spec_accept": 0.679,
"tokens_per_round": 2.62,
"vram_slots": 3780
},
{
"model": "Q2_0",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 147.4,
"prefill_tok_s": 1208.46,
"ttft_s": 107.81,
"decode_tok_s": 67.21,
"decoded": 256,
"spec_accept": 0.691,
"tokens_per_round": 2.64,
"vram_slots": 3697
},
{
"model": "Q2_0",
"tier": "262k",
"prompt_tokens": 259943,
"exit": 0,
"wall_s": 309.8,
"prefill_tok_s": 966.84,
"ttft_s": 270.35,
"decode_tok_s": 60.3,
"decoded": 256,
"spec_accept": 0.623,
"tokens_per_round": 2.43,
"vram_slots": 3525
},
{
"model": "IQ2_XS",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 40.4,
"prefill_tok_s": 461.28,
"ttft_s": 2.28,
"decode_tok_s": 74.38,
"decoded": 256,
"spec_accept": 0.772,
"tokens_per_round": 2.84,
"vram_slots": 4386,
"note": "first pass"
},
{
"model": "IQ2_XS",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 41.8,
"prefill_tok_s": 810.71,
"ttft_s": 4.48,
"decode_tok_s": 73.84,
"decoded": 256,
"spec_accept": 0.79,
"tokens_per_round": 3.24,
"vram_slots": 4336,
"note": "first pass"
},
{
"model": "IQ2_XS",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 65.0,
"prefill_tok_s": 1237.98,
"ttft_s": 25.59,
"decode_tok_s": 71.49,
"decoded": 256,
"spec_accept": 0.856,
"tokens_per_round": 3.28,
"vram_slots": 4057,
"note": "first pass"
},
{
"model": "IQ2_XS",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 97.1,
"prefill_tok_s": 1135.63,
"ttft_s": 56.59,
"decode_tok_s": 64.29,
"decoded": 256,
"spec_accept": 0.707,
"tokens_per_round": 2.76,
"vram_slots": 4005,
"note": "first pass"
},
{
"model": "IQ2_XS",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 158.0,
"prefill_tok_s": 1070.61,
"ttft_s": 120.1,
"decode_tok_s": 59.76,
"decoded": 256,
"spec_accept": 0.726,
"tokens_per_round": 2.69,
"vram_slots": 3923,
"note": "first pass"
},
{
"model": "IQ2_XS",
"tier": "262k",
"prompt_tokens": 259943,
"exit": 0,
"wall_s": 332.4,
"prefill_tok_s": 886.2,
"ttft_s": 293.43,
"decode_tok_s": 52.83,
"decoded": 256,
"spec_accept": 0.628,
"tokens_per_round": 2.53,
"vram_slots": 3565,
"note": "first pass"
},
{
"model": "IQ3_XXS",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 44.1,
"prefill_tok_s": 414.55,
"ttft_s": 2.54,
"decode_tok_s": 60.26,
"decoded": 256,
"spec_accept": 0.864,
"tokens_per_round": 3.52,
"vram_slots": 3304,
"note": "first pass"
},
{
"model": "IQ3_XXS",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 45.8,
"prefill_tok_s": 769.57,
"ttft_s": 4.73,
"decode_tok_s": 62.07,
"decoded": 256,
"spec_accept": 0.855,
"tokens_per_round": 3.24,
"vram_slots": 3263,
"note": "first pass"
},
{
"model": "IQ3_XXS",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 69.7,
"prefill_tok_s": 1107.75,
"ttft_s": 28.61,
"decode_tok_s": 51.4,
"decoded": 256,
"spec_accept": 0.678,
"tokens_per_round": 2.6,
"vram_slots": 3031,
"note": "first pass"
},
{
"model": "IQ3_XXS",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 104.9,
"prefill_tok_s": 1065.31,
"ttft_s": 61.77,
"decode_tok_s": 49.95,
"decoded": 256,
"spec_accept": 0.741,
"tokens_per_round": 2.94,
"vram_slots": 2965,
"note": "first pass"
},
{
"model": "IQ3_XXS",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 172.1,
"prefill_tok_s": 1015.11,
"ttft_s": 128.12,
"decode_tok_s": 45.78,
"decoded": 256,
"spec_accept": 0.665,
"tokens_per_round": 2.35,
"vram_slots": 2901,
"note": "first pass"
},
{
"model": "IQ3_S",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 56.2,
"prefill_tok_s": 396.13,
"ttft_s": 4.14,
"decode_tok_s": 51.52,
"decoded": 256,
"spec_accept": 0.826,
"tokens_per_round": 3.12,
"vram_slots": 2617
},
{
"model": "IQ3_S",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 57.5,
"prefill_tok_s": 736.9,
"ttft_s": 6.35,
"decode_tok_s": 51.6,
"decoded": 256,
"spec_accept": 0.747,
"tokens_per_round": 2.89,
"vram_slots": 2580
},
{
"model": "IQ3_S",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 82.5,
"prefill_tok_s": 1069.83,
"ttft_s": 31.33,
"decode_tok_s": 48.19,
"decoded": 256,
"spec_accept": 0.772,
"tokens_per_round": 2.94,
"vram_slots": 2383
},
{
"model": "IQ3_S",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 115.0,
"prefill_tok_s": 1069.8,
"ttft_s": 61.47,
"decode_tok_s": 48.75,
"decoded": 256,
"spec_accept": 0.786,
"tokens_per_round": 2.9,
"vram_slots": 2347
},
{
"model": "IQ3_S",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 191.8,
"prefill_tok_s": 931.09,
"ttft_s": 138.11,
"decode_tok_s": 40.48,
"decoded": 256,
"spec_accept": 0.661,
"tokens_per_round": 2.71,
"vram_slots": 2310,
"note": "first pass (a download was running)"
}
]
@@ -0,0 +1,35 @@
### Prompt processing (tok/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Q2_0 | 494 | 1,007 | 1,308 | 1,294 | 1,208 | 967 |
| IQ2_XS | 461 | 811 | 1,238 | 1,136 | 1,071 | 886 |
| IQ3_XXS | 415 | 770 | 1,108 | 1,065 | 1,015 | - |
| IQ3_S | 396 | 737 | 1,070 | 1,070 | 931 | - |
### Output (tok/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Q2_0 | 84.3 | 90.3 | 73.6 | 69.0 | 67.2 | 60.3 |
| IQ2_XS | 74.4 | 73.8 | 71.5 | 64.3 | 59.8 | 52.8 |
| IQ3_XXS | 60.3 | 62.1 | 51.4 | 50.0 | 45.8 | - |
| IQ3_S | 51.5 | 51.6 | 48.2 | 48.8 | 40.5 | - |
### Draft acceptance
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Q2_0 | 0.709 | 0.846 | 0.709 | 0.679 | 0.691 | 0.623 |
| IQ2_XS | 0.772 | 0.790 | 0.856 | 0.707 | 0.726 | 0.628 |
| IQ3_XXS | 0.864 | 0.855 | 0.678 | 0.741 | 0.665 | - |
| IQ3_S | 0.826 | 0.747 | 0.772 | 0.786 | 0.661 | - |
### Experts in VRAM
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Q2_0 | 4,176 | 4,123 | 3,833 | 3,780 | 3,697 | 3,525 |
| IQ2_XS | 4,386 | 4,336 | 4,057 | 4,005 | 3,923 | 3,565 |
| IQ3_XXS | 3,304 | 3,263 | 3,031 | 2,965 | 2,901 | - |
| IQ3_S | 2,617 | 2,580 | 2,383 | 2,347 | 2,310 | - |
@@ -0,0 +1,26 @@
{
"mode": "project",
"dir": "per-layer",
"scale": 1.0,
"layer_range": [
4,
44
],
"refusal_harmful_thinkoff": 2.0,
"refusal_harmful_thinkon": 2.0,
"refusal_harmless_thinkoff": 4.0,
"refusal_harmless_thinkon": 6.0,
"broken": 0,
"kl_mean": 0.18598,
"kl_max": 18.26549,
"kl_p99": 3.143571,
"decode_tps": 51.19,
"vector_file": "Qwen3.8-Flash-Next-experimental-speed-projection.gguf",
"base_model_gguf": "unsloth/Qwen3.8-Flash-Next-GGUF UD-Q2_K_XL",
"base_model_sha256_shard1": "a4f3b21e77353999829f2f767e9ac21ce9c71d29a74f2cc9eda48c9bf23c8b86",
"worktree_commit": "37e026e3b",
"criterion_status": "PASS",
"dirty_direction": false,
"note": "projection ablation h -= s*(h.v)*v, v unit-normalized per layer. dir=per-layer",
"branch": "cvec-hooks (github.com/Cudila/llama.cpp)"
}
@@ -0,0 +1,21 @@
# experimental-speed-projection (EXPERIMENTAL, off by default)
`Qwen3.8-Flash-Next-experimental-speed-projection.gguf` is a 480 KB control vector for Qwen3.8-Flash-Next: one unit
direction of 2,560 values per layer (`direction.1` .. `direction.47`, GGUF `controlvector` format). The JSON beside it
is the configuration its authors selected and their scores.
**What it does.** Turned on, Strata removes each direction from the residual stream after layers 4-44
(`h -= (h . v) v` on every hyper-connection stream), the same operation as llama.cpp's `--cvec-mode project` in the
package's patches. The package's own documentation describes the vector as a **refusal-direction projection**: the
model declines far fewer requests (it reports 1 of 50 vs 50 of 50 on its test set), and removing refusals removes a
safety behaviour - you are responsible for what the model writes with it on. It also shifts the model's predictions
slightly on ordinary text (measured here: `bench/results/2026-09-27-esp/`). It is not an optimization in the engine:
on the same text it costs 0.2-0.4% per token; a chat's speed with it on depends on the text the model writes.
**Turning it on.** `START-HERE.bat --setup` asks (default: off), or pass `--experimental-speed-projection on`. With it
loaded, the web app's Sampling drawer and the API field `"experimental_speed_projection": false` switch it off per
request. See `docs/DETAILS.md`, "Experimental speed projection".
**Origin and license.** A local package of the vector (the published original uses filenames containing "refusal";
the bytes are the same), made from Qwen3.8-Flash-Next activations, so the Qwen Community License 1.0 of the model
applies to it. Made for the original Qwen3.8-Flash-Next (not Swift 1.5).
Binary file not shown.
Binary file not shown.
+486
View File
@@ -0,0 +1,486 @@
# Strata - the details
The technical side of Strata: every measured number, the API, images, all settings and how the engine works.
New here? Start with the [README](../README.md) - it has everything you need to install and use it.
> **On this page:** [Speed](#speed-measured) · [Other GPUs](#other-gpus-estimated) · [Which model?](#which-model) ·
> [Requirements](#before-you-start) · [Windows](#windows) · [Linux](#linux) · [API](#using-it) · [Images](#images-vision) ·
> [Troubleshooting](#troubleshooting) · [How it works](#how-it-works)
---
## Speed (measured)
RTX 5070 **12 GB**, Ryzen 5 7600 (6 cores), 64 GB DDR5-5200, Windows, engine 0.1.14 with the settings setup writes
(`--prefill auto`, 8-bit KV above 4K, KV streaming from 64K). One code-agent prompt per length, 256 generated tokens,
MTP speculative decoding on. "262K" is the model's full context window (a 259,943-token prompt). The IQ2_XS row was
measured with Swift 1.5's IQ2_XS, which runs at the original's speed.
### Prompt processing (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 494 | 1,007 | 1,308 | 1,294 | 1,208 | 967 |
| **IQ2_XS** | 461 | 811 | 1,238 | 1,136 | 1,071 | 886 |
| **IQ3_XXS** | 415 | 770 | 1,108 | 1,065 | 1,015 | - |
| **IQ3_S** | 396 | 737 | 1,070 | 1,070 | 931 | - |
| **Coder** | 599 | 1,152 | 1,298 | 1,350 | 1,266 | 1,034 |
### Output (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 84.3 | 90.3 | 73.6 | 69.0 | 67.2 | 60.3 |
| **IQ2_XS** | 74.4 | 73.8 | 71.5 | 64.3 | 59.8 | 52.8 |
| **IQ3_XXS** | 60.3 | 62.1 | 51.4 | 50.0 | 45.8 | - |
| **IQ3_S** | 51.5 | 51.6 | 48.2 | 48.8 | 40.5 | - |
| **Coder** | 53.3 | 50.6 | 53.3 | 50.8 | 44.0 | 42.8 |
Output speed depends on the text as well: speculative decoding runs faster when more of the drafted tokens are
accepted, so a different answer to the same prompt moves it by several percent. Run back to back on the 4K prompt,
0.1.14 writes 88.5 tokens/s and 0.1.12 85.7. The numbers before 0.1.13 (prompts about half as fast):
[`bench/results/2026-09-24-final`](../bench/results/2026-09-24-final/matrix.md); these:
[`bench/results/2026-09-28-speed-0114`](../bench/results/2026-09-28-speed-0114/README.md).
IQ3_XXS and IQ3_S at 262K are not measured: with their 43 / 50 GB of experts, a 260K-token context brings a 64 GB PC
to its memory limit. Use up to 128K with them on 64 GB (setup caps it). IQ3_S (engine 0.1.4 or newer) is only published
for the original model, not for Swift 1.5.
**KV streaming (engine 0.1.5):** at 64K and more, setup keeps the context's KV cache in RAM and only the part the
attention reads in VRAM (`--kv-resident 32768`), so more experts fit on the GPU. Q2_0 at 262K: 50.9 -> 62.6 tokens/s
(1,589 -> 3,872 experts in VRAM); at 128K about +6%. The attention reads exactly the same values (only where the KV lives changes); it
costs ~13.7 KB of RAM per context token (1.7 GB at 128K). Existing installs: run `START-HERE.bat --setup` once to turn
it on.
**4-bit KV cache (engine 0.1.8, optional):** `START-HERE.bat --setup` asks above 8K context (or pass `--kv q4_0`). It
halves the KV cache's memory with a Hadamard rotation before 4-bit rounding (PR #21), about 4% faster at 128K, but it
is measurably less precise on long documents (perplexity +8-12%; needle tests still pass). 8-bit stays the default.
Details: [`bench/results/2026-09-27-kv-q4`](../bench/results/2026-09-27-kv-q4/README.md).
Time to first token is prompt length / prompt speed: with Q2_0 about 4 s at 4K, 25 s at 32K, under 2 minutes at 128K
and 4.5 minutes at 262K (engine 0.1.13 made long prompts about twice as fast, below).
**Faster prompts (engine 0.1.13):** the prompt is read in chunks of up to 8,192 tokens instead of 2,048 (`--prefill
auto`: the largest chunk whose buffers fit in the expert-cache slots it borrows, and a request borrows only what its
prompt needs); the experts are multiplied by llama.cpp's quantized MMQ kernels instead of being expanded to FP16
first; the next layer's experts stream over PCIe while the current layer's attention runs; the PLE block runs for the
whole chunk at once; unpinned experts are copied by helper threads. Measured on the RTX 5070 12 GB, 64 GB RAM,
32K-token prompt: Q2_0 572 -> 1,290 tokens/s, IQ3_S 383 -> 1,208. Through the server (Q2_0, 128K context): 999 tokens
353 -> 438 tokens/s, 6,927 tokens 529 -> 1,077, 28,584 tokens 584 -> 1,249. Output speed is unchanged. Needles 5/5
(1K-262K). Details and the quality check:
[`bench/results/2026-09-28-prefill-speed`](../bench/results/2026-09-28-prefill-speed/README.md). Existing installs
switch to `--prefill auto` the next time START-HERE / setup.sh starts them. The raw numbers:
[`bench/results/`](../bench/results/). The [paper](paper/Strata-Paper.pdf) explains every number.
## Other GPUs (estimated)
Not measured - estimated from the runs above (same CPU and 64 GB RAM): the GPU part scaled by memory bandwidth, the CPU
part by how many more experts the card's VRAM holds. Treat as **±20%**. Numbers are *prompt / output* tokens/s.
The prompt figures predate engine 0.1.13, which about doubled prompt speed on the measured card; how much of that a
card gains depends on its PCIe link (the experts stream over it), so they are still the older estimates.
| GPU | Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| RTX 5060 Ti 16GB | Q2_0 | ~341 / ~80 | ~472 / ~87 | ~501 / ~81 | ~492 / ~72 | ~476 / ~62 | ~435 / ~53 |
| | IQ2_XS | ~291 / ~80 | ~406 / ~77 | ~434 / ~63 | ~426 / ~62 | ~413 / ~51 | ~383 / ~47 |
| | IQ3_XXS | ~249 / ~66 | ~359 / ~65 | ~381 / ~56 | ~374 / ~54 | ~363 / ~45 | - |
| RTX 3090 24GB | Q2_0 | ~355 / ~128 | ~491 / ~140 | ~521 / ~130 | ~512 / ~115 | ~495 / ~100 | ~453 / ~85 |
| | IQ2_XS | ~303 / ~131 | ~422 / ~128 | ~451 / ~103 | ~444 / ~102 | ~430 / ~85 | ~398 / ~78 |
| | IQ3_XXS | ~260 / ~106 | ~374 / ~103 | ~396 / ~89 | ~390 / ~85 | ~378 / ~71 | - |
More VRAM matters more than a faster GPU: every extra GB holds ~700 more experts, and every expert on the GPU is one the
CPU does not have to compute. A 3090's 24 GB takes most of the CPU work away. (Since 0.1.14 the expert profile ranks
all 24,576 experts; before, the cache stopped at 8,000, about 10-14 GB. `tools/make_profile.py` builds a profile from
your own prompts: run the engine once with `--dump-routing trace.bin`, see the tool's help.)
## Which model?
All three are [ISTA-DASLab's GSQ-RCO quantizations](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
of [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next).
| Model | Download | RAM it uses | Speed | Quality |
| --- | ---: | ---: | --- | --- |
| **Q2_0** | 66 GB | ~34 GB experts + ~6 GB | fastest | good |
| **IQ2_XS** | 68 GB | ~36 GB experts + ~6 GB | close to Q2_0 | a bit better |
| **IQ3_XXS** | 76 GB | ~43 GB experts + ~6 GB | slower (more CPU work) | best |
With 64 GB of RAM all three fit (close the browser for IQ3_XXS, and keep its context at 128K or less). With 48 GB only Q2_0 / IQ2_XS may fit. 32 GB is not enough.
### Or: the Coder (half the experts, for code)
**[Qwen3.8-Flash-Next GSQ-RCO Coder](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF)** is
ISTA-DASLab's expert-pruned release: 256 of each layer's 512 experts are kept (still 10 active per token), chosen with
RCO on code, agentic and vision calibration data; its authors report 91.3% of the full model's SWE-bench Verified and
98.7% of LiveCodeBench v6. One size, named IQ1_M for its 1.89 bits per *original* parameter; the kept experts are
stored like IQ3_S (IQ2_S-IQ4_XS gate/up, IQ4_NL/Q2_0 down). Shard 1 is 29.6 GB (experts: 23 GB of RAM), so it runs
on **32 GB of RAM**, and at 262K on 64 GB. Its shard 2 and its vision encoder are the original's files: with the
original installed, setup downloads only shard 1. Strata ships its expert profile (`data/expert-profile-coder.bin`,
the shipped ranking mapped onto the kept experts through the release's `rco-allocation.txt`: 72% of the expert
reads hit the GPU on a 12 GB card). Images work; the experimental speed projection loads and runs on it (it was made
for the full model).
```
START-HERE.bat --setup --family coder
```
### Or: Swift 1.5 (a fine-tune that thinks shorter)
The setup's first question also offers **[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)**,
UkisAI's fine-tune of Qwen3.8-Flash-Next, trained to reach the answer with much less thinking (its authors: 63% fewer
thinking tokens, 1.8x sooner answers, under 1% accuracy loss). Same architecture, the same three sizes, its own
vision encoder; Strata runs it at the same speed (4K, IQ2_XS: 465 prompt / 78.7 output tokens/s, vs 467 / 78.3 for
the original). Its authors recommend **IQ2_XS** (their Q2_0 is marked experimental). Its license is the Swift Open
License 1.0 - read it on the model page.
Our small check (8 reasoning questions, default thinking, IQ2_XS): both models got **8/8**; Swift used **1,234**
output tokens in 28 s, the original **2,682** in 46 s - most of the difference from one question the original
thought about for 1,524 tokens. Not a benchmark, but consistent with the claim.
```
START-HERE.bat --setup --family swift --model IQ2_XS
```
## Before you start
You need **only an NVIDIA driver** (version 580 or newer; update it with the NVIDIA App or from
[nvidia.com/drivers](https://www.nvidia.com/drivers)). Everything else is installed for you the first time.
| | |
| --- | --- |
| GPU | NVIDIA **RTX 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070; RTX 30/40 are untested. |
| RAM | **64 GB** recommended (see the table above). |
| CPU | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. |
| Disk | ~70-80 GB free for the model, ~6 GB for the MTP layer (+1 GB with images). **Q2_0 on an AVX-512 CPU** also writes a one-time ~40 GB copy of its experts for the fast CPU kernel. An NVMe SSD is strongly recommended. |
| OS | Windows 10/11, or Linux (Ubuntu 22.04/24.04 get everything installed automatically). |
What the first start installs: in this folder `.venv/`, `engine/` and `third_party/`; the model files (`models/`,
`packs/`, `mtp/`, 70-120 GB) in **`Strata-data` next to this folder**, so a new copy of Strata (an update unzipped
elsewhere) finds them and sets itself up the same way. The place is remembered per user (`%APPDATA%\Strata\settings.json`,
`~/.config/strata/settings.json`); `--data-dir` chooses another. Installs from before 0.1.16 are moved there by the next
start (a rename on the same drive; files on another drive are used where they are).
Python 3.12 if you have none (for your user account, no admin), a private Python environment, NVIDIA's CUDA libraries
(from pip, ~0.4 GB), the ready-made Strata engine for RTX 30/40/50, the model and the MTP draft layer. If no
ready-made engine fits your PC, it offers to install the build tools (Visual Studio Build Tools + CUDA Toolkit on
Windows, `build-essential` + CUDA on Ubuntu) and compiles the engine for your GPU (asks first; 20-40 minutes once).
---
## Windows
### Double-click `START-HERE.bat`
**The first time** it asks four questions and does the rest:
1. **Which model?** Qwen3.8-Flash-Next (the original) or Swift 1.5 (the fine-tune that thinks shorter).
2. **Which size?** Q2_0, IQ2_XS or IQ3_XXS (it recommends one for your RAM).
3. **How much context?** 8K to 256K tokens (it recommends one for your VRAM).
4. **Images?** yes / no (see [Images](#images-vision)).
Then it downloads and prepares everything (the model is 66-76 GB, so the first start takes a while; an interrupted
download continues where it stopped) and **starts the model**: your browser opens `http://127.0.0.1:8080`, the Strata
app. It has three tabs:
- **Chat:** streaming answers, the model's thinking (folded away once it answers), code with a copy button, pictures when
images are on, and sampling and thinking-level settings. Chats stay in your browser.
- **Monitor:** what the model is doing (reading the prompt, with progress, or writing, at how many tokens/s); GPU load,
VRAM, temperature, power and PCIe traffic; CPU, RAM and disk; the context in use; the last requests.
- **About:** the model and engine settings, and the addresses to connect other apps.
`http://127.0.0.1:8080/?q=your question` opens it with a new chat already asking. The API is at
`http://127.0.0.1:8080/v1` for your apps.
**Every time after that**, `START-HERE.bat` just starts the model (30-90 s to load 34-43 GB into RAM). Nothing is
downloaded again. Closing the window stops the model.
```
START-HERE.bat --setup install another model, or change context / images
START-HERE.bat --model IQ2_XS --context 32768 --vision yes --yes no questions
START-HERE.bat --gguf-dir D:\models\IQ2_XS use GGUF files you already have
START-HERE.bat --data-dir E:\Strata-data keep the model files somewhere else
START-HERE.bat --port 8081 another port
```
With more than one model installed, it asks which one to start. `run-<model>.bat` starts a model directly.
### Chat in the terminal (optional)
```
.venv\Scripts\python chat.py
```
---
## Linux
```bash
./setup.sh
```
The same questions, the same automatic install (it uses `sudo apt` for Python and, only if it has to compile,
for the build tools), and the same start: `http://127.0.0.1:8080`. Later runs of `./setup.sh` (or `./run-<model>.sh`)
start the model directly. Options as on Windows (`./setup.sh --setup`, `--model Q2_0 --yes`, `--gguf-dir /data/Q2_0`).
Terminal chat: `.venv/bin/python chat.py`.
- **Updating:** `git pull`, then `./setup.sh`: it compiles the engine again when its source changed (a minute or
two for the changed files). If that compile fails, it says so and starts the engine you had.
- **Other distributions** (Arch, Fedora, ...): install the C++ compiler and the CUDA Toolkit 13 with your package
manager first (Arch: `sudo pacman -S base-devel cuda`); setup finds `nvcc` on PATH, in `/usr/local/cuda*` and in
`/opt/cuda*`, and does the rest.
- **WSL** works (Ubuntu 24.04 tested), with one limit: the NVIDIA driver pins only about 1 GB of RAM there, so KV
streaming (`--kv-resident`) is off and the KV cache stays in VRAM, and the experts are copied to the GPU from
unpinned RAM (slower prompts than native Linux).
---
## Using it
The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or edit the run script).
| API | Endpoint |
| --- | --- |
| OpenAI Chat Completions (stream and non-stream, tools) | `POST /v1/chat/completions` |
| Anthropic Messages (stream and non-stream, tools) | `POST /v1/messages` |
| Model list / health | `GET /v1/models`, `GET /health` |
| What the model is doing right now | `GET /status` |
| Everything the Monitor tab shows (engine, live state, last requests, hardware) | `GET /metrics` |
```bash
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{
"model": "strata", "messages": [{"role": "user", "content": "Write a haiku about GPUs."}], "max_tokens": 512 }'
```
```python
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none")
r = client.chat.completions.create(model="strata", messages=[{"role": "user", "content": "Hello!"}])
print(r.choices[0].message.content)
```
- **Thinking levels: none, low, medium, high.** The model thinks before it answers (streamed as
`reasoning_content`, Anthropic: `thinking` blocks). Choose how much per request - in the chat page (the "Thinking"
menu), in `chat.py` (`/think low`), or over the API:
| API | how |
| --- | --- |
| OpenAI | `"reasoning_effort": "none" \| "low" \| "medium" \| "high"` (also `"reasoning": {"effort": ...}`, or `"chat_template_kwargs": {"enable_thinking": false}`) |
| Anthropic | `"output_config": {"effort": "low" \| "medium" \| "high"}`, `"thinking": {"type": "disabled"}`, or `"thinking": {"type": "enabled", "budget_tokens": N}` (under 2K = low, under 8K = medium, more = high) |
Without a setting the model uses its own default, **high**. `none` answers at once (fastest); `low` keeps the thinking
short. The levels are instructions the model was trained with, not a hard token limit: on easy questions all three
think briefly, on hard ones `high` thinks longest and is most accurate.
- **Streaming.** With `"stream": true` everything arrives as it is made: the thinking, the answer, and tool calls
(the tool's name first, then its arguments piece by piece, like OpenAI and Anthropic do). While the model reads a
long prompt the stream sends keep-alives, so agents do not time out; the server window prints progress every
15 s, and `GET /status` says what it is doing (`reading the prompt`, `answering`, tokens so far). Closing the
connection or pressing stop in your app really stops the model, so the next request starts at once.
- **Chat apps.** Any app with an "OpenAI-compatible" provider works: base URL `http://127.0.0.1:8080/v1`, any API key.
- **Context.** Chosen in setup (8K-262K). Requests longer than that are refused, never silently cut. A request whose
`max_tokens` would run past the context is refused too (400); agents that always ask for their full output cap
can instead get it shortened to the room left: add `"fit_max_tokens": true` to `strata-<model>.json` (or pass
`--fit-max-tokens` to `serve/server.py`). A prompt that leaves no room at all is still refused.
- **From other devices on your network.** The server listens on your PC only (`127.0.0.1`) unless you say otherwise:
run setup with `START-HERE.bat --setup --host 0.0.0.0 --api-key some-long-secret` (or add `"host": "0.0.0.0"` and
`"api_key": "..."` to `strata-<model>.json`). The server window then prints this PC's addresses
(`from other devices: http://192.168.x.x:8080/`); open that on the other device, or use `.../v1` as an API base URL.
On Windows the firewall blocks it until you allow it: accept its prompt for Python (private networks), or run
`New-NetFirewallRule -DisplayName "Strata 8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow -Profile Private`
in an admin PowerShell, and make sure the network is set to Private.
- **From the internet.** Put a tunnel in front of it, for example [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/):
`cloudflared tunnel --url http://127.0.0.1:8080`. **Set a key first**, or anyone with the link can use your PC:
add `"api_key": "some-long-secret"` to `strata-<model>.json` (or set the `STRATA_API_KEY` environment variable);
clients then send it as their API key.
**Conversation cache.** A request that continues a chat reads only the part after what the engine already holds: the
live session, or one of the checkpoints it keeps in RAM (up to 6, ~118 MB each, taken at the start of each new
assistant turn and every 16K prompt tokens). A checkpoint is used only when the prompt starts with exactly its tokens
and pictures. Engine options: `--prompt-cache N` (0 = off), `--prompt-cache-every N`, `--turn-token ID`.
**Current limits (v1):** one request at a time, and one conversation cached at a time (switching between two chats
re-reads the other one); images only when set up with them (below); no video. **Temperature / top_p / top_k / min_p /
seed** are honored per request (OpenAI and Anthropic fields); with the default adaptive expert tier a sampled result
is not reproducible run to run - for seed-reproducible output add `--adapt-every 100000` (static residency) to the
engine arguments. The run config's optional `sampling` block sets the defaults for requests that leave the fields out
(`"sampling": {"temperature": 1.0, "top_p": 0.95, "top_k": 20}`); a request's own fields always win, and with no
block at all a request without sampling keys decodes greedy. The penalties (`presence_penalty`, `frequency_penalty`,
`repetition_penalty`, with `penalty_last_n` capping how many recent tokens they count over, default 64 when any
penalty is set) ride the same path; they count the tokens the request has consumed, so a repetition penalty
suppresses what the model itself just said, not the prompt alone.
---
## Images (vision)
The model has a vision encoder: [`mmproj-Qwen3.8-Flash-Next-BF16.gguf`](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
(0.9 GB, a 27-layer ViT plus the projector into the language model). It is **optional**: say yes when the setup asks
"Images?", or run it again with `--vision gpu` (or `--vision cpu`). The setup downloads the encoder, builds a small
helper (`strata-vision`, from llama.cpp's `mtmd` library) and adds it to your start script. Nothing else changes.
| Encoder on | Time per picture | Cost |
| --- | --- | --- |
| **GPU** (recommended) | **0.1-0.5 s** (up to 1,024 image tokens) | ~1.4 GB of VRAM is kept free for it, so the expert cache is smaller: text output is a few % slower (table below) |
| CPU | 10-30 s (pictures are scaled down to ~300 image tokens) | nothing on the GPU |
A picture becomes up to 1,024 tokens of the context (a 640x480 photo: 300). The same picture sent again, as chat apps
do on every turn, is encoded only once.
### Sending a picture
**Terminal chat:** type `/image <path to a picture>`, press Enter, then type your question.
```
you> /image C:\Users\me\Pictures\receipt.jpg
(picture attached: receipt.jpg - now type your question)
you> What is the total on this receipt?
```
**OpenAI API** (an `image_url` part: a `data:` URL, an `http(s)://` URL or a local file path):
```python
import base64
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="none")
img = base64.b64encode(open("photo.jpg", "rb").read()).decode()
r = client.chat.completions.create(model="strata", messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img}"}},
{"type": "text", "text": "What is in this picture?"}]}])
print(r.choices[0].message.content)
```
**Anthropic API:** an `image` block with a `base64` (or `url`) source, as usual.
JPEG, PNG, BMP, GIF, WebP, TIFF and AVIF work (the last ones are converted to PNG first; agents such as omp send
WebP). Chat apps with image upload work the same way.
### Speed with images on (4K context, measured)
| Model | Prompt tok/s, images off | Prompt tok/s, images on | Output tok/s, images off | Output tok/s, images on |
| --- | ---: | ---: | ---: | ---: |
| **Q2_0** | 541 | 531 | 90.7 | 86.9 |
| **IQ2_XS** | 468 | 458 | 77.1 | 74.0 |
| **IQ3_XXS** | 411 | 401 | 64.7 | 59.5 |
"Off" is the published setup; "on" runs with the encoder loaded on the GPU and its VRAM kept free, so ~1,000 fewer
experts fit in VRAM and the CPU computes a few more per token: 2-8% slower. For text, turning images on changes
nothing else (the output is bit-identical when the VRAM is the same).
A question about a picture (a 640x480 newspaper page = 300 image tokens, a 328-token prompt; the whole request, encoder
on the GPU, measured through the API):
| Model | Answer ("MEN WALK ON MOON") | Picture encoded | Prompt | Output |
| --- | ---: | ---: | ---: | ---: |
| **Q2_0** | 1.9 s | ~0.1 s | 207 tok/s | 65-73 tok/s |
| **IQ2_XS** | 2.3 s | ~0.1 s | 173 tok/s | 50-60 tok/s |
| **IQ3_XXS** | 2.7 s | ~0.1 s | 144 tok/s | 41-50 tok/s |
(Short prompts run below the 4K prompt speed: a 2,048-token chunk is where the prompt path is efficient. Short answers
run below the long-output speed: the first rounds have no draft yet.)
**How it works inside:** the encoder turns the picture into rows of the same width as the model's word embeddings;
Strata puts them where the prompt has `<|image_pad|>` tokens and gives each one its 2-D position (row and column in the
picture; the model uses interleaved M-RoPE). Answers match llama.cpp's multimodal implementation token for token on our
test images.
---
## Experimental speed projection (EXPERIMENTAL, off by default)
**This is an experiment, not a finished feature.** It ships with Strata but stays off unless you turn it on.
A 480 KB control vector for Qwen3.8-Flash-Next (`data/experimental-speed-projection/`, see its README). After each
of layers 4-44 the engine removes one direction from every hyper-connection stream of the residual: `h -= (h . v) v`,
one unit vector `v` per layer, exactly as llama.cpp does with the package's `--cvec-mode project` patches.
**What it changes.** The vector's own package describes it as a **refusal-direction projection**: with it the model
declines far fewer requests (it reports 1 of 50 vs 50 of 50 on its test set), and removing refusals removes a safety
behaviour - you are responsible for what the model writes with it on. It also shifts ordinary answers a little
(measured below). It is not an optimization in the engine: on the same text it costs 0.2-0.4% per token. What a
chat's tokens/s does with it on depends on the text the model writes (length, repetition, how well the drafts land),
so measure it on your own prompts; the Monitor marks every request ESP or stock.
**Turning it on (at setup).** `START-HERE.bat --setup` asks "Turn on the experimental speed projection?" (default:
no), or pass `--experimental-speed-projection on` (`off`, or a path to another vector GGUF). Only for the original
Qwen3.8-Flash-Next, not Swift 1.5. It writes these engine flags (llama.cpp's) into `strata-<model>.json`:
```
--control-vector-scaled <Strata>\data\experimental-speed-projection\Qwen3.8-Flash-Next-experimental-speed-projection.gguf:1.0
--control-vector-layer-range 4 44 --cvec-mode project --cvec-dir per-layer
```
The engine log then says `control vector mode = project, dir = per-layer, layers 4..44 (41 steered)`, and the web
app's About tab lists it. (`--cvec-mode add` is llama.cpp's stock additive mode, for additive vectors.)
**Per request.** A loaded vector is on for every request unless it says otherwise: the web app's Sampling drawer has
a switch, and the API takes `"experimental_speed_projection": false` in the request body (OpenAI and Anthropic; a
config default goes in `"sampling": {"experimental_speed_projection": false}`). Switching drops the conversation
cache once, since the model state was computed the other way. Switched off, the output is token-for-token the stock
model's.
**Measured here** (Q2_0, fixed experts, 2,557 teacher-forced tokens of code, a document and a chat): the top-1 token
changes at 10% of positions, mean KL from the stock model 0.063 nats (max 4.1), perplexity +15% on code, +2.3% on
the document, +0.4% on the chat. Details: `bench/results/2026-09-27-esp/`.
---
## Troubleshooting
| Symptom | What to do |
| --- | --- |
| `the NVIDIA driver is too old` | Update the driver (NVIDIA App or nvidia.com/drivers), restart, run `START-HERE.bat` again. |
| Python or the build tools could not be installed | Install what it names (links are printed), then run it again. Everything already done is kept. |
| `port 8080 is already in use` | Strata is already running (look for its window), or another program uses the port: `START-HERE.bat --port 8081`. |
| `cudaHostRegister ... out of memory` in the log | Normal on Windows: the engine pins the experts in per-layer slices instead. Only a problem if the load then fails. |
| The first start takes minutes | It is reading 34-55 GB into RAM; the second start is faster while the files are in the OS cache. |
| The PC freezes for a few minutes at the start | Normal, most of all the first time (the server window says when it happens): the engine loads the experts into RAM, pins part of it for the GPU and sizes the expert cache. Wait; don't close the window. Still frozen after 10 minutes: restart the PC, close other programs, try again, or pick a smaller size. |
| `the engine stopped unexpectedly (exit code ...)` | The engine process ended mid-answer - usually out of RAM (Linux ends the biggest program: `sudo dmesg \| grep -i -E 'killed process\|out of memory'`). The next request starts it again by itself. If it repeats: close other programs or pick a smaller size. The server also warns at start when the model's experts leave less than ~6 GB of RAM for everything else. |
| Slow output, disk light busy | Not enough free RAM: close other programs, or choose Q2_0 / IQ2_XS. |
| `prompt ... exceeds the context` | The request is longer than the context you chose: run setup again with a bigger `--context`. |
| Slower than the tables | The monitor plugged into the GPU and other GPU programs take VRAM from the expert cache; RAM running below its rated speed (enable EXPO/XMP in the BIOS) slows the CPU half. |
| `this server was started without the vision encoder` | The model was set up for text only: run setup again with `--vision gpu`. |
| A picture is refused or `cannot read the image` | The file is not a picture Pillow can open (JPEG, PNG, WebP, GIF, BMP, TIFF, AVIF work). |
| Pictures are slow (10-30 s) | The encoder runs on the CPU: run setup again with `--vision gpu` (needs ~1.4 GB of VRAM). |
| A request never finishes: "reading the prompt", GPU "100%" at low power | The GPU ran out of VRAM (engines before 0.1.9 could end with ~30 MiB free at large contexts). Run `START-HERE.bat` once to get engine 0.1.9 or newer; the log then says `... MiB of VRAM free with everything loaded` (a few hundred) and names the `--vram-reserve-mib` to add if it is low. |
| Generation stops mid-answer, GPU "100%", one CPU core busy | Fixed in engine 0.1.12 (issue #29, a race in the CPU expert pool on big-VRAM cards). Since then a request that stops moving ends with an error instead of hanging (after 2 minutes; 1 minute from 0.1.13): the log says `no progress for ... s ... (issue #29)` with where it stopped, and the next request starts the engine again. If you see that line, please open an issue with it. Engine 0.1.13 adds a stall report under it (what every expert-pool thread and the GPU handshake were doing, memory and page faults) and, on Windows, a `strata-stall-<pid>.dmp` file with every thread's stack: attach both. (`STRATA_WATCHDOG_S` sets the time in seconds; 0 turns it off.) Engine 0.1.14 fixes the stall those reports found (issue #31: with the IQ packs the host could wait forever inside the NVIDIA driver while copying experts in a verify window; the experts are now copied by a GPU kernel, `--pcie-mode dma` restores the old way). |
| `out of memory: cudaFuncSetAttribute` in the log (IQ3_XXS, long prompt) | Fixed in engine 0.1.15: CUDA loaded a kernel's code when it was first needed, and mid-prompt there was no VRAM left for it. Run `START-HERE.bat` (Windows) or `./setup.sh` (Linux) once to update. |
| Anything else | The engine log is `strata-<model>.log` in this folder. |
---
## How it works
<p align="center"><img src="paper/tiers.svg" width="760" alt="memory tiers"></p>
- **GPU (VRAM):** attention and DeltaNet mixers, the gated-residual weights, routers, shared experts, output head, the MTP
draft layer, the KV cache (from 64K: only its most-read part, the rest streams from RAM), and an **expert cache** that fills the rest of VRAM with the most-used experts (it adapts to
the conversation while you chat).
- **RAM:** all 24,576 experts, pinned. The CPU computes the experts that are not on the GPU **in place**, at the same time
as the GPU works on the cached ones (AVX-512 / AVX2 kernels, ggml's for the i-quants).
- **SSD:** the 28.8 GB n-gram table, read a few rows per token through the OS cache.
- **Speculation:** the model's own MTP layer drafts up to 3 tokens; one pass over all 48 layers checks them. 2.4-3.2
tokens per pass on average. When the reply repeats the context (code edits, quoted text), **prompt lookup** (engine
0.1.7) drafts up to 5 tokens from the earlier copy, but only where its measured acceptance and cost say it pays:
code edits 6-11% faster, other text unchanged. The drafts are checked like the MTP's, so the output is the same.
- **Prompts** are processed in 2,048-token chunks with the experts streamed to the GPU over PCIe.
The full story, with measurements, bottlenecks and what comes next: **[docs/paper/Strata-Paper.pdf](paper/Strata-Paper.pdf)**.
---
## Credits and licenses
Strata itself: [MIT](../LICENSE). The model files are not part of it; their licenses apply to them (below).
- Model: [Qwen/Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team; quantizations:
[ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF).
The Coder: [ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF)
(Apache-2.0 per its card); its support in Strata came from @pjgmobile's PR #54.
Swift 1.5: [ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
by UkisAI. Their licenses apply to the weights.
- [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp) (MIT): the i-quant formats, the GPU dot products and
dequantizers transcribed in `src/kernels/cuda/iq_kernels.cu`, the CPU backend linked for the i-quant experts, the
`mtmd` library behind the image encoder (`tools/vision/`), and `gguf-py` used by the tools. See
`third_party/ggml/LICENSE`.
- Ideas from [Splash](https://github.com/incoai/splash), [ninfer](https://github.com/Neroued/ninfer) and
[HyperQwen](https://github.com/syv-ai/HyperQwen); references in the paper.
- The web app's font: [Outfit](https://github.com/Outfitio/Outfit-Fonts) (SIL Open Font License 1.1, see
`serve/web/fonts/OFL.txt`). Its Monitor tab started from @code-martin's dashboard idea (PR #22).
- The experimental speed projection's vector (`data/experimental-speed-projection/`): Qwen Community License 1.0,
made from the model's activations (see its README).
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

+4 -3
View File
@@ -454,8 +454,9 @@ private:
// ---- architecture guard (P1.S2). The engine is specialised to ONE model; anything else must be
// refused with a precise error rather than silently mis-run.
struct Qwen4ExpGuard {
uint32_t block_count = 48, hidden = 2560, experts = 512, experts_used = 10, head_count = 24,
head_count_kv = 2;
uint32_t block_count = 48, hidden = 2560, experts = 0, experts_used = 0, head_count = 24,
head_count_kv = 2; // 0 = presence-only: pruned variants (GSQ-RCO Coder) legitimately ship
// fewer experts than the canonical 512; the graph reads the true value
};
inline std::string check_architecture(const GgufFile& g, const Qwen4ExpGuard& want = {}) {
@@ -477,7 +478,7 @@ inline std::string check_architecture(const GgufFile& g, const Qwen4ExpGuard& wa
for (const auto& r : reqs) {
const MetaValue* v = g.get(r.key);
if (!v) return std::string("missing ") + r.key;
if (v->u != r.want)
if (r.want && v->u != r.want)
return std::string(r.key) + " = " + std::to_string(v->u) + ", expected " + std::to_string(r.want);
}
return {}; // empty == ok
+40 -3
View File
@@ -36,6 +36,7 @@
#include "strata/core/weights.hpp"
#include "strata/kernels/gr.hpp"
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/ngram.hpp"
#include "strata/kernels/ple.hpp"
@@ -206,10 +207,24 @@ struct QsaState {
int8_t* v_q = nullptr;
uint16_t* k_scale = nullptr;
uint16_t* v_scale = nullptr;
int32_t* page_table = nullptr; ///< (n_pages,) logical page -> physical page
/// PR #21: Q4_0 KV with Walsh-Hadamard rotation (qsa_set_kv_q4, kv_q4.hpp): 144 B per cell and head
bool kv_q4 = false;
uint8_t* k_q4 = nullptr;
uint8_t* v_q4 = nullptr;
int32_t* page_table = nullptr; ///< (n_pages,) logical page -> physical page (-1: not resident, streamed)
int64_t n_pages = 0;
int64_t max_cells = 0;
/// KV STREAMING (docs/kv-streaming-design.md, `kv_stream.hpp`). `kv_mode` 0: every page in VRAM, identity
/// table (n_slots == n_pages, no host copy). 1: streamed - the authoritative K/V in `host`, `n_slots` pages
/// in VRAM, `map` resolves the selection's blocks on device. 2: a ring (the MTP drafter's window) - `n_slots`
/// pages at `block % n_slots`, with the host copy for a resume. The pool pointers above point at the slots.
int kv_mode = 0;
int64_t n_slots = 0;
strata::kernels::KvHostPools host;
strata::kernels::KvStreamMap map;
int64_t idx_pooled_rows = 0; ///< rows of `idx_pooled` (a ring, which has no indexer, keeps 2)
float* idx_tail = nullptr; ///< (idx_block - 1, idx_dim): the raw tail of the block being filled
float* idx_dead = nullptr; ///< (idx_dim,): the spare slot's key, CONSTANT for the sequence
float* idx_pooled = nullptr; ///< (max_cells/idx_block + 2, idx_dim)
@@ -242,13 +257,35 @@ struct QsaState {
/// Plan v0.3 P7: the RoPE cos/sin table (max_cells x n_rot/2 x 2 floats, 64 MiB at 262K) is identical in every
/// QSA layer. `with_rope = false` sizes a state that borrows it; `share_rope` points `st` at another state's table
/// instead of building a copy (the session builds it once, in the first QSA layer).
uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_rope = true);
uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_rope = true, int64_t ring_cells = 0);
/// KV streaming: keep `cells` cells of each QSA layer in VRAM and the rest in pinned host memory (0: all in VRAM,
/// the default). Set before sizing and initializing the session; a context that fits in `cells` is not streamed.
/// Also puts the MTP drafter's K/V in a ring of its window (`ring_cells` of qsa_state_bytes/init; -1 forces a fully
/// resident state).
void qsa_set_kv_resident(int64_t cells);
int64_t qsa_kv_resident();
/// The fewest resident cells a streamed layer may have: one verify window's selections (8 queries x 2,051 cells
/// in whole blocks) must fit at once, with room to spare.
int64_t qsa_kv_resident_min();
/// Pinned host bytes the streamed states hold (their host copies).
uint64_t qsa_kv_host_bytes();
/// Plan v0.3 P7: store K/V as INT8 with FP16 scales per 64 values (half the VRAM of FP16). Set before sizing and
/// initializing the session; default off until gate G-C accepts it.
void qsa_set_kv_int8(bool enabled);
bool qsa_kv_int8();
/// PR #21: store K/V as Q4_0 after a Hadamard rotation (`--kv q4_0`): 576 B per cell, vs 1,056 in INT8.
void qsa_set_kv_q4(bool enabled);
bool qsa_kv_q4();
/// The state's KV format for the block-moving functions of kv_stream.hpp (kKvF16 / kKvInt8 / kKvQ4).
inline int qsa_kv_format(const QsaState& st) {
return st.kv_q4 ? strata::kernels::kKvQ4 : st.kv_int8 ? strata::kernels::kKvInt8 : strata::kernels::kKvF16;
}
uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st,
const QsaState* share_rope = nullptr);
const QsaState* share_rope = nullptr, int64_t ring_cells = 0);
/// KV streaming: the pools a reader sees (the VRAM slots) and, when streamed, make the selection's blocks resident.
strata::kernels::QsaAttnPools qsa_attn_pools(const QsaState& st);
void qsa_kv_resolve(const QsaState& st, const ModelGeometry& g, const int32_t* ids, const int32_t* steps, int64_t n_q,
int64_t cap, void* stream);
/// Zeroes the pools AND the indexer, so a fresh sequence matches the reference's own `zeros()`. The KV pool
/// matters even for cells that are never attended, because `kv_gather` reads whatever the selection names.
void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream);
+8
View File
@@ -45,7 +45,14 @@ public:
int64_t window = 32768);
/// The prompt's length: prefill() skips the cells the attention window can never reach again.
void set_prompt_len(int64_t n) { prompt_len_ = n; }
/// At most this many drafts per round (below max_t - 1): a window longer than the MTP's comes from elsewhere.
void set_max_drafts(int k) { max_drafts_ = k; }
uint64_t vram_bytes() const { return vram_; }
/// The draft layer's K/V state (read-only: --serve's STRATA_STATE_HASH check hashes it)
const QsaState& kv_state() const { return st_; }
/// KV streaming: refill the ring of the drafter's window from its host copy for a sequence that continues at
/// `upto` (a conversation-cache resume). No-op unless the drafter's K/V is a ring.
void kv_restore(int64_t upto);
/// The main model's embedding and head, and the verify window's final residuals (T rows, hc*n_embd each).
bool bind(const WeightTable& wt, const NativeHead* head, const float* window_R, std::string& err);
@@ -82,6 +89,7 @@ private:
const NativeHead* head_ = nullptr;
const float* window_R_ = nullptr;
int max_t_ = 0;
int max_drafts_ = 1 << 30;
int64_t n_vocab_ = 0;
uint64_t vram_ = 0;
cudaStream_t cs_ = nullptr;
+51
View File
@@ -0,0 +1,51 @@
// include/strata/core/progress.hpp - issue #29: whether a request is still moving, and where it is.
//
// The engine's host loop and the GPU wait on each other through flags; a protocol bug there does not crash, it
// spins forever (the GPU "100%", one CPU core busy, no tokens). `--serve` runs a watchdog thread over this: a
// request whose heartbeat stops for `STRATA_WATCHDOG_S` seconds (default 60; 0 = off) ends the engine with the
// stage it was stuck in, and the server starts it again instead of hanging. Tokens, prompt chunks and verify
// windows beat; the stage is two relaxed stores per layer, which the token path does not notice.
#pragma once
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
namespace strata::core {
struct Progress {
std::atomic<uint64_t> beats{0};
std::atomic<bool> busy{false};
std::atomic<const char*> where{"idle"};
std::atomic<int64_t> detail{-1};
std::atomic<int64_t> since_ms{0}; ///< when `where` was set (steady clock): how long a stage has lasted
std::atomic<uint64_t> ticks{0}; ///< layers served: a window that still moves, slowly, against one that stopped
};
inline int64_t progress_now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch())
.count();
}
/// What the watchdog prints about a part of the engine when it fires (the expert pool, the verify window).
using DiagFn = void (*)(std::FILE*);
inline std::atomic<DiagFn>& diag_pool_fn() { static std::atomic<DiagFn> f{nullptr}; return f; }
inline std::atomic<DiagFn>& diag_verify_fn() { static std::atomic<DiagFn> f{nullptr}; return f; }
inline Progress& progress() {
static Progress p;
return p;
}
inline void progress_at(const char* where, int64_t detail = -1) {
Progress& p = progress();
p.where.store(where, std::memory_order_relaxed);
p.detail.store(detail, std::memory_order_relaxed);
p.since_ms.store(progress_now_ms(), std::memory_order_relaxed);
}
inline void progress_beat() { progress().beats.fetch_add(1, std::memory_order_relaxed); }
inline void progress_tick() { progress().ticks.fetch_add(1, std::memory_order_relaxed); }
} // namespace strata::core
+29
View File
@@ -21,9 +21,12 @@
// selection, native indexer) and a profile-filled VRAM expert tier with its residency table on the device.
#pragma once
#include <cstdio>
#include "strata/core/expert_source.hpp"
#include "strata/core/layer.hpp"
#include "strata/core/session.hpp"
#include "strata/kernels/sampler.hpp"
#include <cuda_runtime.h>
@@ -51,6 +54,9 @@ public:
Verifier(const Verifier&) = delete;
Verifier& operator=(const Verifier&) = delete;
/// The watchdog's view of the window in flight (issue #31): the layer, the GPU's sequence, the flags.
void diag(std::FILE* f) const;
/// `max_t` <= kVerifyMaxT. `head` may be null (the canonical head is then run per token).
bool init(const WeightTable& wt, const ModelGeometry& g, SessionState& ss, const VerifyHits& hits,
const NativeHead* head, int max_t, std::string& err);
@@ -58,6 +64,21 @@ public:
/// One window: `tokens[0..T)` at positions pos0.., the pool served per layer; `out[t]` = argmax after token t.
/// The PLE rows are gathered here from `ss.ple_prev` and the tokens. Captures the T-token graph on first use.
bool run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool, void* user, int32_t* out, std::string& err);
/// The sampling the verify window's head applies (temperature / top_p / top_k / seed). Set per
/// request; greedy by default. The sampling itself runs OUTSIDE the captured graph - its
/// parameters would otherwise be baked forever - so this can change between requests freely.
void set_sampling(const strata::kernels::SamplerParams& sp) {
sampling_ = sp; // row t of a window at pos0 draws Philox(seed, pos0 + t): see run()
}
/// The penalty-history row for `sampling_.penalty_last_n`: ONE row of `history_len` int32 slots, most
/// recent token LAST, unused front slots -1 (the kernel reads only the tail window). Null disables the
/// penalties entirely - the neutral run's sampling call is byte-for-byte what it was. The engine
/// re-uploads the request's tail before every window; the pointer must stay alive across the request.
void set_history(const int32_t* history, int history_len) {
hist_d_ = history;
hist_len_ = history_len;
}
/// Keep the first `n_keep` (1..T) tokens of the last window; advances `ss.ple_prev` by them.
bool commit(int n_keep, std::string& err);
@@ -83,6 +104,14 @@ public:
private:
bool capture(int T, std::string& err);
strata::kernels::SamplerParams sampling_ = [] {
strata::kernels::SamplerParams s;
s.greedy = true;
s.temperature = 0.0f;
return s;
}(); ///< greedy by default; per-request via set_sampling
const int32_t* hist_d_ = nullptr; ///< penalty-history row (set_history); null = no penalties apply
int hist_len_ = 0;
bool capture_commit(std::string& err);
bool record_window(int T, cudaStream_t cs, std::string& err);
+26
View File
@@ -0,0 +1,26 @@
// include/strata/kernels/cpu/iq_avx2.hpp - AVX-2 multi-token dot products for the i-quant expert
// formats (IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, IQ3_S) against Q8_K activations (ggml's block_q8_K).
//
// The AVX-512 kernel's scheme on the CPUs without it (AMD Zen 2/3, Intel Core 12th-14th gen): the weights of a
// 32-value chunk are decoded once per verify window and every token applies them with five instructions.
#pragma once
#include <cstddef>
#include <cstdint>
namespace strata::kernels::cpu {
bool iq256_supported(int ggml_type) noexcept;
/// ff[t][r] = silu(gate_r . a[t]) * (up_r . a[t]), rows [r0, r1); gate rows at blob, up rows at blob + up_off.
void iq256_gu_rows(int ggml_type, const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act,
int nt, float* const* ff, int r0, int r1);
/// out[t][r] = w_r . a[t], rows [r0, r1).
void iq256_rows(int ggml_type, const uint8_t* w, size_t row_bytes, int n, const void* const* act, int nt,
float* const* out, int r0, int r1);
/// out[t][r] = w_r . h[t] for IQ4_NL (type 20) rows against Q8_0 activations (ggml's block_q8_0).
/// IQ4_NL is a 32-value-block format, so this does not go through iq256_rows (QK_K blocks, Q8_K acts).
void iq4nl256_down_rows(const uint8_t* w, size_t row_bytes, int n, const void* const* hq, int nt,
float* const* out, int r0, int r1);
} // namespace strata::kernels::cpu
+54 -5
View File
@@ -14,16 +14,29 @@
// THE COMPLETION PROTOCOL, because this is where a pool usually goes wrong. `run()` waits for `done == n`
// AND for every worker to PARK. Waiting only for `done` is not enough: a worker can still be inside the
// drain loop after its last `done` increment, and the host resetting `head` underneath it would let that
// worker claim a job from the NEXT batch before the next batch has been published. The
// `done`-then-`parked` pair makes the handover unambiguous, and the second wait costs a few hundred cycles
// against a layer that takes milliseconds.
// worker claim a job from the NEXT batch before the next batch has been published.
//
// **AND `parked` IS NOT ENOUGH EITHER (issue #29).** A worker that went to sleep (after `kSpinBeforeSleep`) is
// still counted as parked when it wakes, so for a moment after it has seen a new epoch the host believes it is
// idle. On a card with most experts in VRAM the workers sleep in the middle of a request, tiny batches finish
// before a sleeper is awake, and that moment comes round constantly: a late worker could claim from the NEXT
// batch while the host was still writing it, run a job twice, or add to `done` after the host had reset it -
// and `done != n` then never ended, with the GPU waiting on the pool forever. So every claim carries its
// batch: `head` is one 64-bit word `epoch | njobs | index`, a claim is a CAS that only succeeds for the epoch
// the worker woke for, and a late worker's claim simply fails. A claim that succeeds belongs to the current
// batch, whose description the host cannot change until that job's `done` has landed.
#pragma once
#include "strata/kernels/cpu/expert.hpp"
#include "strata/kernels/cpu/native_expert.hpp"
#include <atomic>
#include <cstdio>
#include <memory>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <thread>
#include <vector>
@@ -89,6 +102,8 @@ public:
/// threads on six cores. `false` is the A/B arm and exists so the change is measurable rather than
/// asserted - the counter it moves is `pool phases ... drain`, which is host-side and needs no profiler.
explicit ExpertPool(int n_workers = 0, bool pin = true, bool host_works = true);
/// The watchdog's view of the pool (issue #31): the batch, the counters, every thread's state.
void diag(std::FILE* f) const;
~ExpertPool();
ExpertPool(const ExpertPool&) = delete;
ExpertPool& operator=(const ExpertPool&) = delete;
@@ -136,10 +151,31 @@ public:
repark = ms_repark_;
}
/// **A PARKED WORKER SPINS FOR THIS LONG, THEN SLEEPS.** The park is a `_mm_pause` spin because a layer's
/// batches are microseconds apart and a wake-up from the OS costs more than that. But a spin that never ends
/// keeps every worker's core at 100% while the engine waits for a request - issue #4, "CPU 50% even when
/// doing nothing" (7 of the 5700X's 16 threads). Between requests the workers block on `sleep_cv_`. NOT only
/// between requests: with most experts in VRAM (a 24 GB card) many layers have no CPU work, so the workers
/// also sleep mid-request - which the claim protocol above must survive (issue #29).
/// `STRATA_POOL_SPIN_US` overrides it (a test knob: a short spin makes the workers sleep constantly).
static constexpr std::chrono::milliseconds kSpinBeforeSleep{20};
/// A pool wait that sees no completion for this long is a bug; the engine stops with a message instead of
/// spinning forever, and the server starts it again (issue #29).
static constexpr std::chrono::seconds kStall{60};
private:
void worker(int id);
void drain(int id, ExpertScratch& scratch);
void drain(int id, ExpertScratch& scratch, uint32_t epoch);
void run_phase(int mode, int n_tasks);
/// Claim the next job of batch `epoch`, or -1 (that batch is exhausted, or it is not the current one).
int claim(uint32_t epoch);
/// Publish the batch whose description the caller has just written: reset `done`, then `head`, then the epoch.
uint32_t begin_batch(int n);
/// The host's waits, bounded by `kStall`.
void wait_parked(const char* what);
void wait_done(int n);
/// Bump `epoch_`, and wake the workers that went to sleep. Every publish goes through here.
void publish();
int n_ = 0;
bool host_works_ = true;
@@ -164,11 +200,24 @@ private:
//
// The counter was diagnostic only - `pauses()` was read in one place, to print a number nothing branched on
// - so it is deleted rather than amortised. `alignas(64)` then stops the remaining four sharing.
alignas(64) std::atomic<uint32_t> head_{0};
// issue #31 diagnostics: each worker's state (kParked, kSleeping, kBetween, or the job it runs) and the host's
// (kIdle, kWaitParked, kWaitDone, or its job), printed by the serve watchdog through `diag`
static constexpr int32_t kParked = -1, kSleeping = -2, kBetween = -3, kIdle = -10, kWaitParked = -11,
kWaitDone = -12;
std::unique_ptr<std::atomic<int32_t>[]> wstate_;
std::atomic<int32_t> hstate_{kIdle};
std::atomic<int64_t> hstate_ms_{0};
alignas(64) std::atomic<uint64_t> head_{0}; // epoch << 32 | njobs << 16 | next index (issue #29)
alignas(64) std::atomic<uint32_t> done_{0};
alignas(64) std::atomic<uint32_t> parked_{0};
alignas(64) std::atomic<uint32_t> epoch_{0};
alignas(64) std::atomic<bool> stop_{false};
// The sleep after `kSpinBeforeSleep`. `sleepers_` is how `publish` knows whether anyone needs waking, so the
// token path pays one uncontended load per publish and never takes the mutex while the workers spin.
alignas(64) std::atomic<uint32_t> sleepers_{0};
std::mutex sleep_mu_;
std::condition_variable sleep_cv_;
std::chrono::microseconds spin_before_sleep_{kSpinBeforeSleep};
std::vector<std::thread> threads_;
std::vector<ExpertScratch> scratch_; // one per worker: no allocation, no false sharing of the hot data
// run_split state: mode 0 = whole experts, 1 = gate/up row parts, 2 = down row parts
+56
View File
@@ -0,0 +1,56 @@
// include/strata/kernels/cvec.hpp - a control vector on the residual stream: the engine's side of llama.cpp's
// `llama_adapter_cvec` with the `--cvec-mode` / `--cvec-dir` extension of the `experimental-speed-projection`
// package (its `02-cvec-projection-mode.patch`, and `01-qwen4exp-cvec-hooks.patch` for WHERE it applies).
//
// After layer l's FFN write - llama.cpp's `build_cvec(res_hc, il)` right after the layer's second
// `build_hc_combine` - every one of the hc residual streams h of every token becomes
//
// project: h <- h - s_l (h . v_l) v_l v_l = d / |d|, s_l = |d|, d = the scaled direction
// add: h <- h + d llama.cpp's stock additive control vector
//
// for l in [first, last]. `d` is the GGUF's `direction.<src>` times the file's scale (summed over files), with
// src = l (per-layer) or one fixed layer (single:L, project mode only), exactly as llama.cpp's loader places it;
// layer 0 has no direction. Off unless a vector is loaded (--control-vector-scaled); a loaded one is switched
// per request through a device flag, so the captured graphs are the same either way. With the flag off the
// result is bit-identical to the engine without a vector: the kernel then only applies the pending residual
// write, with the fused read's own arithmetic.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace strata::kernels {
struct Cvec {
const float* dir = nullptr; ///< device, n_layers * n_embd: project = the unit v_l, add = d_l; zero rows elsewhere
const float* s = nullptr; ///< device, n_layers: project = s_l, add = 1; 0 = the layer is not steered
const int* on = nullptr; ///< device int: 0 = this request runs the stock model
int mode = 0; ///< 0 = project, 1 = add
int first = 0, last = -1; ///< the steered layers, inclusive
int64_t n_embd = 0, hc = 0;
bool loaded() const { return dir != nullptr; }
/// Layer l's FFN write is followed by the vector (with a real direction there). Decided at load time, never
/// per request: it changes where the residual write happens, so the graphs depend on it.
bool covers(int64_t l) const { return loaded() && l >= first && l <= last && l < (int64_t) steered.size() && steered[(size_t) l]; }
std::vector<bool> steered; ///< host copy of s_l != 0
};
/// The loaded vector (empty until `cvec_upload`).
const Cvec& cvec();
/// Upload a vector built by the loader: `dir` is n_layers * n_embd, `s` n_layers (see `Cvec`). Starts ON.
bool cvec_upload(const std::vector<float>& dir, const std::vector<float>& s, int mode, int first, int last,
int64_t n_embd, int64_t hc, std::string& err);
/// The per-request switch. Synchronizes the device when it changes, so call it between requests.
void cvec_set_enabled(bool on);
bool cvec_enabled();
/// Layer `layer`'s vector on T tokens' residual stacks (`R + t * r_ld`, hc streams of n_embd). With `write`, the
/// pending FFN write `R += bo * 2 sigmoid(inj / hc)` (the fused read's arithmetic) is applied first, for callers
/// whose writes are folded into the next layer's read; without it, R must already hold the layer's output.
void cvec_apply(float* R, int64_t layer, int64_t T, int64_t r_ld, const float* bo, int64_t bo_ld, const float* inj,
int64_t inj_ld, bool write, void* stream);
} // namespace strata::kernels
+61
View File
@@ -0,0 +1,61 @@
// include/strata/kernels/kv_q4.hpp - Q4_0 KV storage with Walsh-Hadamard rotation for the QSA layers (`--kv q4_0`).
//
// From PR #21 (code-martin). K and V are rotated by the orthonormal 256-point Hadamard matrix H before they are
// quantized to ggml's q4_0 (32 values per block: an fp16 scale + 16 bytes of 4-bit codes, 144 B per head per cell,
// 576 B per cell for K and V of both heads, vs 1,056 B in INT8). The rotation spreads a head's outlier channels
// over all 256 dimensions, which is what makes 4 bits usable. The query is rotated the same way, so
// <Hq, Hk> = <q, k> and the scores are unchanged, and the attention output (a mix of rotated values) is rotated
// back with H (self-inverse). What is lossy is the 4-bit rounding itself: measured, not assumed, in
// bench/results/2026-09-27-kv-q4.
//
// With KV streaming (--kv-resident) the codes live in the host copy and the resident slots like INT8's (KvHostPools
// k_q4/v_q4, the same block granule), so both options combine.
#pragma once
#include "strata/kernels/qsa.hpp"
#include <cstdint>
namespace strata::kernels {
inline constexpr int QK4_0 = 32;
#pragma pack(push, 1)
struct block_q4_0 {
uint16_t d; // scale (fp16 bits)
uint8_t qs[QK4_0 / 2]; // 32 4-bit codes: element j in the low nibble of qs[j], j + 16 in the high one
};
#pragma pack(pop)
static_assert(sizeof(block_q4_0) == 18, "block_q4_0 must be 18 bytes");
/// Bytes per cell and KV head: 8 blocks of 32 for head_dim 256 -> 144.
inline uint64_t kv_q4_bytes_per_head(int head_dim) { return (uint64_t) (head_dim / QK4_0) * sizeof(block_q4_0); }
/// Bytes per cell (one token, one layer): K and V of every KV head.
inline uint64_t kv_q4_bytes_per_cell(const QsaShapes& s) {
return (uint64_t) s.n_head_kv * kv_q4_bytes_per_head((int) s.head_dim) * 2;
}
/// Orthonormal Fast Walsh-Hadamard Transform of rows of 256 floats (scale 1/16): its own inverse.
void fwht256_cuda(const float* src, float* dst, int64_t n_rows, void* stream);
inline void fwht256_inplace_cuda(float* data, int64_t n_rows, void* stream) { fwht256_cuda(data, data, n_rows, stream); }
/// Append the (already rotated) cell at step[kStepPos]. With a host copy (KV streaming) it is written there too,
/// and to VRAM only if its block is resident.
void kv_append_q4_step(uint8_t* k_q4, uint8_t* v_q4, const int32_t* page_table, const int32_t* step,
const float* kcur, const float* vcur, const QsaShapes& s, void* stream,
const KvHostPools* host = nullptr);
/// The prompt path: T consecutive (rotated) cells from pos0, K/V [T, n_head_kv, 256]; also into `stage` (identity
/// layout, the one-layer staging pool of a streamed session) when given.
void kv_append_q4(uint8_t* k_q4, uint8_t* v_q4, const int32_t* page_table, int64_t pos0, int64_t T, const float* K,
const float* V, const QsaShapes& s, void* stream, const KvHostPools* host = nullptr,
const KvHostPools* stage = nullptr);
/// Gather step[kStepWidth] cells named by `ids` into FP16 scratch [id][kv_head][head_dim] (still rotated).
void kv_gather_q4_step(const uint8_t* k_q4, const uint8_t* v_q4, const int32_t* page_table, const int32_t* ids,
const int32_t* step, int64_t max_ids, const QsaShapes& s, uint16_t* k_scratch,
uint16_t* v_scratch, void* stream);
} // namespace strata::kernels
+5 -2
View File
@@ -12,6 +12,7 @@
// the FP16 pools: codes `[page][kv_head][page_size][head_dim]`, scales `[page][kv_head][page_size][head_dim/64]`.
#pragma once
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/qsa.hpp"
#include <cstdint>
@@ -25,9 +26,11 @@ inline uint64_t kv_q8_bytes_per_cell(const QsaShapes& s) {
return (uint64_t) s.n_head_kv * s.head_dim * 2 + (uint64_t) s.n_head_kv * (s.head_dim / KV_Q8_GROUP) * 2 * 2;
}
/// Append the cell at step[kStepPos] (graph-capturable: position and page come from device memory).
/// Append the cell at step[kStepPos] (graph-capturable: position and page come from device memory). With a host
/// copy (KV streaming, `kv_stream.hpp`) the cell is written there too, and to VRAM only if its block is resident.
void kv_append_q8_step(int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale, const int32_t* page_table,
const int32_t* step, const float* kcur, const float* vcur, const QsaShapes& s, void* stream);
const int32_t* step, const float* kcur, const float* vcur, const QsaShapes& s, void* stream,
const KvHostPools* host = nullptr);
/// Gather step[kStepWidth] cells named by `ids` into FP16 scratch `[id][kv_head][head_dim]`; the grid is sized by
/// `max_ids` (capacity), the kernel reads the real count from `step`.
+98
View File
@@ -0,0 +1,98 @@
// include/strata/kernels/kv_stream.hpp - KV streaming for the QSA layers (docs/kv-streaming-design.md).
//
// A streamed QSA layer keeps its AUTHORITATIVE K/V in pinned, device-mapped host memory, laid out exactly as a
// fully resident pool with the identity page table would be. VRAM holds a pool of `n_slots` pages (one page =
// `page_size` = 4 cells = one indexer block, all KV heads, K and V: 4,224 B in int8), and the page table becomes a
// RESIDENCY MAP: `page_table[block]` is the slot holding that block, or -1.
//
// Every reader (`qsa_decode_attn_*`, `kv_gather_*`) is unchanged: it goes through the page table as before. What
// changes is that after the selection and before the attention, `kv_stream_resolve` makes every block the
// selection names resident - on device, inside the captured graphs:
// 1. one block of 1,024 threads walks the selected ids: a resident block gets this call's epoch and its clock
// reference bit, a missing block is claimed once (page_table -1 -> -2) into a miss list;
// 2. the same block picks one victim slot per miss with a CLOCK sweep (second chance) that never takes a slot
// this call uses, re-points the page table (old block -> -1, new block -> slot);
// 3. a copy kernel reads the missed blocks from the host copy (zero-copy, over PCIe) into their slots.
// Writers (`kv_append_*`) write the host copy always, and the slot only if the block is resident, so the slots
// never go stale and nothing needs invalidating - not after a rejected draft, not on a conversation-cache resume.
//
// A RING (the MTP drafter, which only ever reads its last `window` cells) uses the same host copy with a static
// table `block -> block % n_slots` and no resolve: the ring is refilled from the host copy on a resume.
#pragma once
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/qsa_decode_attn.hpp"
#include <cstdint>
namespace strata::kernels {
/// The host copy of one layer's K/V: device-mapped pointers (UVA) into pinned memory, identity layout
/// `[block][kv_head][page_size][head_dim]`. All null when the layer is fully resident.
struct KvHostPools {
uint16_t* k_pool = nullptr; ///< fp16 mode
uint16_t* v_pool = nullptr;
int8_t* k_q = nullptr; ///< int8 mode: codes
int8_t* v_q = nullptr;
uint16_t* k_scale = nullptr; ///< int8 mode: fp16 scale per 64 values
uint16_t* v_scale = nullptr;
uint8_t* k_q4 = nullptr; ///< q4_0 mode (kv_q4.hpp): block_q4_0 codes, 144 B per cell and head
uint8_t* v_q4 = nullptr;
bool present() const { return k_pool != nullptr || k_q != nullptr || k_q4 != nullptr; }
};
/// The KV storage format, for the functions below that move whole blocks (`fmt`): fp16, int8 (+ scales), q4_0.
/// (A bool `int8` argument still reads as kKvF16 / kKvInt8.)
enum KvFormat : int { kKvF16 = 0, kKvInt8 = 1, kKvQ4 = 2 };
/// The residency map of a streamed layer, all device memory at fixed addresses (the graphs bake them in).
struct KvStreamMap {
int32_t* page_table = nullptr; ///< (n_blocks,) block -> slot, or -1
int32_t* slot_block = nullptr; ///< (n_slots,) slot -> block, or -1
int32_t* slot_stamp = nullptr; ///< (n_slots,) the resolve epoch that last used the slot
int32_t* slot_ref = nullptr; ///< (n_slots,) clock reference bit
int32_t* ctl = nullptr; ///< kKvCtlInts: epoch, hand, misses of the last call, overflow, u64 counters
int32_t* miss_block = nullptr; ///< (n_slots,)
int32_t* miss_slot = nullptr; ///< (n_slots,)
int64_t n_blocks = 0;
int64_t n_slots = 0;
};
inline constexpr int kKvCtlInts = 16; ///< [0] epoch [1] hand [2] misses [3] overflow; u64 at [4] misses, [6] lookups, [8] calls
/// Bytes of the map's arrays besides the page table (which the state already has): 5 per-slot ints + ctl.
inline uint64_t kv_stream_map_bytes(int64_t n_slots) { return (uint64_t) n_slots * 4 * 5 + kKvCtlInts * 4; }
/// Every block evicted: page table all -1, slots free, counters zero.
void kv_stream_reset(const KvStreamMap& m, void* stream);
/// Make every block named by the selections of `n_q` queries resident (ids [n_q][cap], width from
/// steps[q * kStepCount + kStepWidth]). Capturable. `n_slots` must hold the distinct blocks of one call
/// (n_q x (cap / page_size + 2)); a call that cannot sets ctl[3] (see `kv_stream_counters`).
void kv_stream_resolve(const KvStreamMap& m, const QsaAttnPools& slots, const KvHostPools& host, int fmt,
const int32_t* ids, const int32_t* steps, int64_t n_q, int64_t cap, const QsaShapes& s,
void* stream);
/// The static ring table `block -> block % n_slots`.
void kv_ring_table(int32_t* page_table, int64_t n_blocks, int64_t n_slots, void* stream);
/// Copy blocks [b0, b1) of the host copy into the pool pages `page_table` names (host-side table `phys(b)`
/// computed as `b % n_slots`: the ring restore). Not capturable.
void kv_ring_restore(const QsaAttnPools& slots, const KvHostPools& host, int fmt, int64_t b0, int64_t b1,
int64_t n_slots, const QsaShapes& s, void* stream);
/// Copy the first `n_blocks` blocks of the host copy into a fully resident (identity-layout) pool: the prompt
/// path's staging of one layer. Not capturable (DMA).
void kv_stage_from_host(const QsaAttnPools& stage, const KvHostPools& host, int fmt, int64_t n_blocks,
const QsaShapes& s, void* stream);
struct KvStreamCounters {
uint64_t misses = 0, lookups = 0, calls = 0;
bool overflow = false;
};
/// Synchronous read of the counters (debug and the end-of-request summary).
KvStreamCounters kv_stream_counters(const KvStreamMap& m);
/// Bytes of one block (page) of K and V together.
uint64_t kv_block_bytes(const QsaShapes& s, int fmt);
} // namespace strata::kernels
@@ -27,4 +27,12 @@ void native_ple_postops(const float* projected_key, const float* hidden,
const PleWeights& weights, const NativePlePostopsBuffers& buffers,
void* stream);
/// The prompt path: T tokens at once, element for element the arithmetic of T calls of native_ple_postops with
/// the history advanced after each. `key` (T x 10240) holds the projected keys and is normalized in place;
/// `hidden` (T x 10240) becomes the result; `value` T x 2560; `query_norm` (T x 10240, the normalized hidden, then
/// the normalized gated values), `gated` (T x 10240) and `gate` (T x 4) are scratch; `history` (9 rows per
/// channel) is advanced past the T tokens.
void native_ple_postops_batch(float* key, float* hidden, const float* value, float* history, const PleWeights& w,
float* query_norm, float* gated, float* gate, int T, void* stream);
} // namespace strata::kernels
+9 -3
View File
@@ -110,7 +110,9 @@ inline QsaShapes qsa_real_shapes() {
QsaShapes s;
s.n_head = 24; s.n_head_kv = 2; s.head_dim = 256; s.n_rot = 64;
s.idx_n_head = 4; s.idx_dim = 128; s.idx_block = 4; s.idx_top_k = 2048;
s.page_size = 512;
// one page = one indexer block (4 cells): the granule KV streaming keeps resident (kv_stream.hpp). The readers
// resolve a row per cell anyway, so the page size costs them nothing (measured: identical output and speed).
s.page_size = 4;
return s;
}
@@ -353,8 +355,12 @@ void qsa_attend_step(const float* q, const uint16_t* k_scratch, const uint16_t*
const int32_t* step, int64_t max_ids, const QsaShapes& s, float* attn, float* weights,
void* stream);
/// The cell's position comes from `step`, so this is the form a graph may contain.
struct KvHostPools; // kv_stream.hpp
/// The cell's position comes from `step`, so this is the form a graph may contain. With a host copy (KV streaming)
/// the cell is written there too, and to VRAM only if its block is resident.
void kv_append_step(uint16_t* k_pool, uint16_t* v_pool, const int32_t* page_table, const int32_t* step,
const float* kcur, const float* vcur, const QsaShapes& s, void* stream);
const float* kcur, const float* vcur, const QsaShapes& s, void* stream,
const KvHostPools* host = nullptr);
} // namespace strata::kernels
@@ -27,6 +27,8 @@ struct QsaAttnPools {
const int8_t* v_q = nullptr;
const uint16_t* k_scale = nullptr; ///< fp16 [page][kv_head][page_size][head_dim / 64]
const uint16_t* v_scale = nullptr;
const uint8_t* k_q4 = nullptr; ///< q4_0 block_q4_0 [page][kv_head][page_size][head_dim / 32 * 18]
const uint8_t* v_q4 = nullptr;
const int32_t* page_table = nullptr;
};
+5 -6
View File
@@ -1,12 +1,10 @@
// include/strata/kernels/sampler.hpp - the sampler chain, host-callable (P2.S2).
//
// penalties -> top_k -> top_p -> temperature -> pick
// penalties -> top_k -> min_p -> top_p -> temperature -> penalties -> pick
//
// The ORDER is specified in docs/sampling.md, transcribed from llama.cpp's own chain. Two facts there are
// easy to get backwards: temperature comes AFTER the truncation filters, and penalties come AFTER temperature.
//
// PENALTIES ARE NOT IMPLEMENTED YET: they need the token history, which is not in this signature, so a caller
// passing a non-zero `penalty_last_n` gets a loud failure rather than a silently unpenalised sample.
// The ORDER is specified in docs/sampling.md, transcribed from llama.cpp's own chain. Three facts there are
// easy to get backwards: temperature comes AFTER the truncation filters, penalties come AFTER temperature as
// well as before the selection, and min_p cuts BEFORE top_p (both on the pre-temperature logits).
#pragma once
#include <cstdint>
@@ -16,6 +14,7 @@ namespace strata::kernels {
struct SamplerParams {
int top_k = 20; // 0 disables the filter
float top_p = 0.95f; // 1.0 disables the filter
float min_p = 0.0f; // 0 disables; keeps tokens with p >= min_p * p_max (a prefix of the top_k list)
float temperature = 1.0f; // <= 0 means greedy
int min_keep = 1; // top_p keeps at least this many
int penalty_last_n = 0; // 0 disables; the window over `history` to count occurrences in
+3
View File
@@ -36,6 +36,9 @@ public:
void native(const uint16_t* X, int ggml_type, const void* W_blocks, float* Y, int64_t T, int64_t N, int64_t K,
int64_t ldy = 0, float beta = 0.0f);
/// Caller-owned buffers only: the scratch and workspace moved (the prompt path laid its buffers out again).
void rebind(uint16_t* scratch, int64_t scratch_elems, void* workspace, size_t ws_bytes);
uint16_t* scratch() const { return scratch_; }
int64_t scratch_elems() const { return scratch_elems_; }
void* stream() const { return stream_; }
+7 -2
View File
@@ -5,6 +5,8 @@
// the kernel walks the chunk's tokens in order inside one launch.
#pragma once
#include "strata/kernels/kv_stream.hpp"
#include <cstdint>
namespace strata::prefill {
@@ -35,7 +37,7 @@ void gdn_recurrence(float* state, const float* h, const float* gate, const float
// ---- MoE
/// softmax over 512, top-10 (ties to the lower id), weights renormalised over the ten (the native router).
void route(const float* logits, int32_t* ids, float* weights, int64_t T, void* stream);
void route(const float* logits, int32_t* ids, float* weights, int64_t T, int64_t n_expert, void* stream);
/// Expert blob (Strata pack layout, Q2_0) -> BF16 matrices: gate/up interleaved [1280, 2560], down [2560, 640].
void blob_dequant(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream);
/// h16[n, r] = fp16(silu(gu[n, 2r]) * gu[n, 2r + 1]) (the interleaved expert gate/up)
@@ -63,11 +65,14 @@ void gate_attn(const float* attn, const float* q_full, uint16_t* out16, int64_t
/// K, V: [T, 2, 256].
void kv_append(const float* K, const float* V, int64_t T, int64_t pos0, const int32_t* page_table, int64_t page_size,
uint16_t* k_pool, uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale,
void* stream);
void* stream, const strata::kernels::KvHostPools* host = nullptr,
const strata::kernels::KvHostPools* stage = nullptr);
/// fp32 -> fp16 bits and fp32 -> bf16, n elements (the two activation images of the prompt GEMMs).
void to_f16(const float* x, uint16_t* y, int64_t n, void* stream);
void to_bf16(const float* x, uint16_t* y, int64_t n, void* stream);
/// y = fp32(fp16(x)): what an FP16 store of x would read back (the FP16 indexer-key experiment)
void round_f16(const float* x, float* y, int64_t n, void* stream);
/// Expert blob -> FP16 (Q2_0 values are exact in FP16).
void blob_dequant_f16(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream);
+73
View File
@@ -0,0 +1,73 @@
// include/strata/prefill/moe_mmq.hpp - prompt-speed plan step 2b: the prompt path's experts through llama.cpp's MMQ
// kernels (ggml-cuda mmq.cuh, MIT): the weights stay quantized and the activations are rounded to q8_1, the
// products run on int8 tensor cores. The dequantize-to-FP16 + cuBLAS path wrote ~10 MB of FP16 per expert and
// multiplied in FP16; this reads the ~1.4-2 MB expert once. A group of experts is gathered into one buffer
// (`gather_*`, one launch per expert as its blob arrives) and multiplied in one launch per product.
#pragma once
#include <cstddef>
#include <cstdint>
namespace strata::prefill::mmq {
/// This build has the MMQ path (the ggml sources were available to the build).
bool built();
/// MMQ covers this ggml type (the i-quants and Q2_0 the packs use; IQ1_M is not covered).
bool supported(int ggml_type);
/// Bytes of one expert's gate+up ([2*n_ff, n_embd]) or down ([n_embd, n_ff]) weights in `ggml_type`.
size_t matrix_bytes(int ggml_type, int64_t rows, int64_t cols);
/// Bytes of `rows` activation rows of `cols` values quantized for MMQ (the row padded to 512 values).
size_t q8_bytes(int64_t rows, int64_t cols);
/// q8_1 activations for MMQ against weights of `ggml_type`: row i of the output is row ids[i] of x (or row i when
/// ids is null); `x` has `ld` floats per row.
void quantize(const float* x, const int32_t* ids, void* xq, int ggml_type, int64_t cols, int64_t ld, int64_t rows,
void* stream);
/// One launch over n experts whose weights lie `expert_bytes` apart from `w`: for expert e, the activation rows
/// [bounds[e], bounds[e+1]) of `xq` (bounds on the device, n+1 entries) times its [w_rows, w_cols] matrix into
/// dst rows of the same indices (`ld_dst` floats apart, via `ids`: dst row = ids[row], an identity table works).
/// `total_rows`: the rows of xq; `max_rows`: the most rows one expert has (the launch grid).
struct Product {
const void* w = nullptr;
int type = -1;
int64_t w_rows = 0, w_cols = 0;
size_t expert_bytes = 0;
int n = 0;
const void* xq = nullptr;
const int32_t* bounds = nullptr;
const int32_t* ids = nullptr;
int64_t total_rows = 0, max_rows = 0;
float* dst = nullptr;
int64_t ld_dst = 0;
};
/// The launch context (llama.cpp's MMQ keeps a small scratch pool for its stream-k fixup). One per prompt path.
class Context {
public:
Context();
~Context();
Context(const Context&) = delete;
Context& operator=(const Context&) = delete;
void run(const Product& p, void* stream);
private:
void* ctx_ = nullptr;
};
/// A GGUF-native expert (gate at `gate`, up at `up`, down at `down`, each its GGUF rows) into a group buffer's
/// slot: gate rows then up rows at `gu_dst`, down at `d_dst`.
void gather_native(const void* gate, const void* up, size_t gu_half_bytes, const void* down, size_t d_bytes,
void* gu_dst, void* d_dst, void* stream);
/// A Strata-pack Q2_0 expert blob (codes and fp16 scales in separate planes, gate/up rows interleaved) into GGUF
/// Q2_0 blocks: gate/up [1280, 2560] at `gu_dst` (rows stay interleaved), down [2560, 640] at `d_dst`. Same values.
void gather_strata_q2(const uint8_t* blob, void* gu_dst, void* d_dst, void* stream);
/// h[r, k] = silu(gate) * up of GU rows [2 n_ff wide]: interleaved (gate 2k, up 2k+1: the Strata pack) or split
/// (gate k, up n_ff + k: GGUF). FP32 out (the down product's quantizer reads floats).
void swiglu(const float* gu, float* h, int64_t rows, int64_t n_ff, bool interleaved, void* stream);
/// dst[i] = i for i < n (the identity row map MMQ's MoE mode writes through).
void iota(int32_t* dst, int64_t n, void* stream);
} // namespace strata::prefill::mmq
+14
View File
@@ -48,6 +48,16 @@ public:
core::ExpertSource* src, const core::ExpertCache* cache, const int32_t* host_res, int64_t chunk,
void* stream, std::string& err, void* borrow = nullptr, uint64_t borrow_bytes = 0);
/// With borrowed buffers: lay them out again for chunks of `chunk` tokens (at most `init`'s) in `borrow` - a
/// request lends only the slots its prompt needs. The stream must be idle (between prompts).
bool relayout(int64_t chunk, void* borrow, uint64_t borrow_bytes, std::string& err);
int64_t chunk() const;
/// The share of the streamed experts' bytes DMA-able straight from pinned RAM (1 = all). Sizes the streamed
/// ring (a big one only pays when the copy engine, not the host copies, is the limit); set before bytes_needed.
static void set_pinned_share(double share);
static double pinned_share();
/// Device bytes `init` needs for a chunk of `chunk` tokens (what a borrowed region must hold).
static uint64_t bytes_needed(const core::ModelGeometry& g, const core::SessionState& ss, int64_t chunk);
@@ -62,11 +72,15 @@ public:
/// K/V from them. The prefill stream is synchronized before the call.
std::function<bool(const float* R_rows, int64_t T, int64_t pos0, std::string& err)> on_chunk;
/// Checked before every chunk: true stops the prompt early (`run` returns false with err "cancelled").
std::function<bool()> should_stop;
/// The vision path: HOST rows (n_embd floats) indexed by absolute position, read in place of the token
/// embedding where non-null (an image's <|image_pad|> cells). Null (default): every position embeds its token.
const float* const* embd_rows = nullptr;
private:
bool carve(std::size_t T, void* alloc); // the device buffers of a chunk (prefill.cpp's Alloc)
struct Impl;
std::unique_ptr<Impl> impl_;
PrefillStats stats_;
+52
View File
@@ -0,0 +1,52 @@
// include/strata/spec/draft_policy.hpp - per verify round: the MTP's window, or a lookup (suffix) window?
//
// The suffix drafter (prompt lookup) proposes the tokens that followed an earlier repeat of the context. Taken
// whenever it proposes more than the MTP, it lost 2-8% on ordinary text: a long lookup window costs much more to
// verify than the MTP's usual 3-4 tokens, and it is only worth that when enough of it is accepted. llama.cpp's
// lookup decoding answers the same problem with confidence thresholds on its n-gram statistics; here the policy
// learns both sides online and compares expected committed tokens per millisecond:
//
// MTP E = the mean tokens a window of that size has committed (EMA), at the measured cost of that size
// lookup E(k) = 1 + q + q^2 + ... + q^k for k <= the proposal, q = the acceptance rate of lookup drafts whose
// match was about as long (4 buckets of match length, decayed counts), at the measured cost of k + 1
//
// and takes the lookup window only when its best E/cost beats the MTP's by `margin`. Costs are the measured round
// times per window size (EMA; sizes not seen yet are scaled from seen ones by a prior shape), so the policy adapts
// to the machine and the context length. It only chooses which drafts to verify: the output is unchanged.
#pragma once
#include <array>
namespace strata::spec {
class DraftPolicy {
public:
static constexpr int kMaxT = 8;
static constexpr int kBuckets = 4;
explicit DraftPolicy(int max_t, double margin = 0.03);
struct Pick {
bool lookup = false;
int t = 1; // window size (1 + drafts)
};
/// `t_mtp`: the MTP's window; `lookup_k`: the lookup proposal's length (0 = none); `match`: its match length.
Pick choose(int t_mtp, int lookup_k, int match) const;
/// After the round: the window it used, the drafts accepted, and the round's time (verify + commit + draft).
void observe(bool lookup, int t, int accepted, int match, double round_ms);
double lookup_rate(int match) const; // current q for a match length
double cost_ms(int t) const; // measured or scaled round time of a window of t tokens
private:
static int bucket(int match);
double mtp_tokens(int t) const;
int max_t_;
double margin_;
std::array<double, kMaxT + 1> cost_{}, cost_n_{}; // round ms by window size
std::array<double, kMaxT + 1> mtp_tok_{}, mtp_n_{}; // tokens committed by MTP windows of that size
std::array<double, kBuckets> ok_{}, bad_{}; // lookup drafts accepted / windows cut short, decayed
};
} // namespace strata::spec
+137 -2
View File
@@ -270,11 +270,127 @@ class OutputParser:
"""Incremental parser of the model's text. Feed deltas; get events. A tag split across deltas is held back
until it is complete, so clients never see `<tool_` or `</thi`."""
def __init__(self, thinking: bool = True, tools: list[dict] | None = None):
def __init__(self, thinking: bool = True, tools: list[dict] | None = None, stream_tools: bool = False):
self.state = "reasoning" if thinking else "content"
self.buf = ""
self.lead = False
self.schemas = {t.get("name"): t for t in tools or []}
# stream_tools: a tool call is also reported while it is being written - "tool_start" (its name and id) as
# soon as the name is known, then "tool_args" pieces of its JSON arguments (string parameters character by
# character; other types whole, once complete) - before the final "tool_call". Without it, a client sees
# nothing until the call is complete, which for a large file write can be many minutes.
self.stream_tools = stream_tools
self._reset_scan()
def _reset_scan(self):
self.sp = 0 # how much of self.buf (the call body) the scanner has consumed
self.ss = "name" # name -> between -> str|raw -> ... -> done
self.scall = None # the ToolCall being streamed (its id is reused by the final event)
self.sfirst = True
self.sval_started = False
self.sdeclared = {}
def _scan(self) -> list[Event]:
"""Advance the streaming view of the call body in self.buf (see stream_tools)."""
out = []
def args(s):
if s:
out.append(Event("tool_args", s, call=self.scall))
while True:
rest = self.buf[self.sp:]
if self.ss == "name":
a = rest.find("<function=")
b = rest.find(">", a + 10) if a >= 0 else -1
if b < 0:
return out
name = rest[a + 10:b]
self.scall = ToolCall(name=name, arguments={})
props = ((self.schemas.get(name) or {}).get("parameters") or {}).get("properties") or {}
self.sdeclared = {k: (v or {}).get("type") for k, v in props.items()}
out.append(Event("tool_start", call=self.scall))
args("{")
self.sp += b + 1
self.ss = "between"
elif self.ss == "between":
stripped = rest.lstrip()
self.sp += len(rest) - len(stripped)
if stripped.startswith("<parameter="):
b = stripped.find(">")
if b < 0:
return out
pname = stripped[11:b]
args(("" if self.sfirst else ",") + json.dumps(pname) + ":")
self.sfirst = False
self.sp += b + 1
if self.sdeclared.get(pname) == "string":
args('"')
self.ss, self.sval_started = "str", False
else:
self.ss = "raw"
elif stripped.startswith("</function>"):
args("}")
self.sp += len("</function>")
self.ss = "done"
else:
return out # a tag still arriving (or trailing text): wait
elif self.ss == "str":
if not self.sval_started:
if not rest:
return out
if rest[0] == "\n": # the value's leading newline is not part of it
self.sp += 1
rest = rest[1:]
self.sval_started = True
end = rest.find("</parameter>")
if end >= 0:
value = rest[:end]
if value.endswith("\n"):
value = value[:-1]
args(json.dumps(value)[1:-1] + '"')
self.sp += end + len("</parameter>")
self.ss = "between"
continue
safe = len(rest) - self._hold(rest, ("</parameter>",))
if safe > 0 and rest[safe - 1] == "\n": # may be the trailing newline before </parameter>
safe -= 1
if safe > 0:
args(json.dumps(rest[:safe])[1:-1])
self.sp += safe
return out
elif self.ss == "raw":
end = rest.find("</parameter>")
if end < 0:
return out
value = rest[:end]
if value.startswith("\n"):
value = value[1:]
if value.endswith("\n"):
value = value[:-1]
try:
v = json.loads(value)
except ValueError:
v = value
args(json.dumps(v, ensure_ascii=False))
self.sp += end + len("</parameter>")
self.ss = "between"
else:
return out
def _close_scan(self) -> list[Event]:
"""A streamed call that ended without a clean </function>: close its JSON so clients can still parse it."""
out = []
if self.scall is None or self.ss == "done":
return out
tail = ""
if self.ss == "str":
tail += '"'
elif self.ss == "raw":
tail += json.dumps(self.buf[self.sp:].strip("\n"))
tail += "}"
out.append(Event("tool_args", tail, call=self.scall))
self.ss = "done"
return out
def _hold(self, text: str, tags: tuple[str, ...]) -> int:
"""Length of the longest suffix of `text` that is a proper prefix of one of `tags`."""
@@ -325,17 +441,36 @@ class OutputParser:
self.state = "call"
else:
i = self.buf.find(CALL_END)
if self.stream_tools:
if i >= 0:
whole, self.buf = self.buf, self.buf[:i] # scan only the body
out += self._scan()
out += self._close_scan()
self.buf = whole
else:
out += self._scan()
if i < 0:
return out
body = self.buf[:i]
self.buf = self.buf[i + len(CALL_END):]
name = body.strip()[len("<function="):].split(">", 1)[0]
out.append(Event("tool_call", call=parse_tool_call(body, self.schemas.get(name))))
call = parse_tool_call(body, self.schemas.get(name))
if self.scall is not None:
call.id = self.scall.id
out.append(Event("tool_call", call=call))
self._reset_scan()
self.state, self.lead = "content", True
def finish(self) -> list[Event]:
"""End of generation: flush whatever is held (an unterminated tool call is returned as content)."""
out = []
if self.state == "call" and self.stream_tools and self.scall is not None:
out += self._scan() # the output ended inside a call that was already announced
out += self._close_scan()
out.append(Event("tool_call", call=self.scall))
self.buf = ""
self._reset_scan()
return out
if self.buf:
kind = {"reasoning": "reasoning", "content": "content"}.get(self.state, "content")
text = self.buf if self.state != "call" else CALL_START + self.buf
-152
View File
@@ -1,152 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Strata</title>
<style>
:root { --bg:#f6f7f9; --card:#fff; --fg:#1b1f24; --mute:#667085; --line:#e3e6ea; --acc:#2f6fed; --me:#e9f0ff; --ok:#1a7f37; }
@media (prefers-color-scheme: dark) {
:root { --bg:#111418; --card:#1a1e24; --fg:#e7e9ec; --mute:#9aa3ae; --line:#2b313a; --acc:#6b9bff; --me:#1f2b44; --ok:#3fb950; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width:860px; margin:0 auto; padding:16px; display:flex; flex-direction:column; gap:12px; min-height:100vh; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:14px 16px; }
h1 { font-size:20px; margin:0; display:flex; align-items:center; gap:8px; }
.dot { width:10px; height:10px; border-radius:50%; background:var(--mute); display:inline-block; }
.dot.on { background:var(--ok); }
.mute { color:var(--mute); font-size:13px; }
code { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:1px 6px; font-size:13px; }
.grid { display:grid; grid-template-columns: max-content 1fr; gap:4px 12px; margin-top:8px; font-size:14px; }
#log { flex:1; display:flex; flex-direction:column; gap:10px; overflow-y:auto; min-height:240px; }
.msg { padding:10px 12px; border-radius:10px; white-space:pre-wrap; word-wrap:break-word; }
.msg.user { background:var(--me); align-self:flex-end; max-width:85%; }
.msg.bot { background:var(--card); border:1px solid var(--line); }
.msg img { max-width:220px; max-height:160px; display:block; border-radius:6px; margin-bottom:6px; }
details { color:var(--mute); font-size:13px; margin-bottom:6px; }
form { display:flex; gap:8px; align-items:flex-end; flex-wrap:wrap; }
textarea { flex:1; min-width:200px; min-height:52px; max-height:200px; resize:vertical; padding:10px; border-radius:10px;
border:1px solid var(--line); background:var(--card); color:var(--fg); font:inherit; }
button, label.btn { border:1px solid var(--line); background:var(--card); color:var(--fg); border-radius:10px; padding:10px 14px;
font:inherit; cursor:pointer; }
button.primary { background:var(--acc); border-color:var(--acc); color:#fff; }
button:disabled { opacity:.5; cursor:default; }
.row { display:flex; gap:14px; align-items:center; flex-wrap:wrap; font-size:13px; color:var(--mute); }
#att { font-size:13px; color:var(--mute); }
</style>
</head>
<body>
<main>
<div class="card">
<h1><span id="dot" class="dot"></span> Strata <span id="state" class="mute">connecting ...</span></h1>
<div class="grid">
<span class="mute">Model</span><span id="model">-</span>
<span class="mute">Context</span><span id="ctx">-</span>
<span class="mute">Images</span><span id="img">-</span>
<span class="mute">OpenAI API</span><span><code id="oai"></code> (any API key<span id="keynote"></span>)</span>
<span class="mute">Anthropic API</span><span><code id="ant"></code></span>
</div>
<p class="mute" style="margin:10px 0 0">Point any app with an "OpenAI-compatible" or "Anthropic" provider at these
addresses. Close the Strata window to stop the model.</p>
</div>
<div id="log" class="card"></div>
<div class="card">
<form id="f">
<textarea id="q" placeholder="Ask something ... (Enter sends, Shift+Enter = new line)"></textarea>
<label class="btn" id="attbtn" hidden>Picture<input id="file" type="file" accept="image/png,image/jpeg,image/gif,image/bmp" hidden></label>
<button class="primary" id="send">Send</button>
<button type="button" id="stop" disabled>Stop</button>
</form>
<div class="row" style="margin-top:8px">
<label>Thinking <select id="think" style="padding:4px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--fg)">
<option value="none">off - answer at once</option>
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high - best on hard questions, slowest</option>
</select></label>
<span id="att"></span>
<button type="button" id="clear" style="padding:4px 10px">New chat</button>
<input id="key" type="password" placeholder="API key" hidden style="padding:6px;border-radius:8px;border:1px solid var(--line)">
</div>
</div>
</main>
<script>
const $ = id => document.getElementById(id);
const base = location.origin;
$("oai").textContent = base + "/v1";
$("ant").textContent = base + "/v1/messages";
let history = [], image = null, ctrl = null;
async function status() {
try {
const h = await (await fetch("/health")).json();
$("dot").classList.add("on"); $("state").textContent = "running";
$("model").textContent = h.model; $("ctx").textContent = h.max_context.toLocaleString() + " tokens";
$("img").textContent = h.images ? "on" : "off (run setup again and answer yes to images)";
$("attbtn").hidden = !h.images;
if (h.api_key) { $("key").hidden = false; $("keynote").textContent = " - this server needs its key"; }
} catch (e) { $("state").textContent = "not reachable - is the Strata window still open?"; }
}
status();
function bubble(cls, text) {
const d = document.createElement("div"); d.className = "msg " + cls; d.textContent = text || "";
$("log").appendChild(d); $("log").scrollTop = $("log").scrollHeight; return d;
}
$("file").onchange = () => {
const f = $("file").files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => { image = r.result; $("att").textContent = "picture: " + f.name; };
r.readAsDataURL(f);
};
$("clear").onclick = () => { history = []; $("log").innerHTML = ""; };
$("stop").onclick = () => ctrl && ctrl.abort();
$("q").addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("f").requestSubmit(); } });
$("f").onsubmit = async e => {
e.preventDefault();
const text = $("q").value.trim(); if (!text) return;
$("q").value = "";
const content = image ? [{type: "image_url", image_url: {url: image}}, {type: "text", text}] : text;
const u = bubble("user", "");
if (image) { const im = document.createElement("img"); im.src = image; u.appendChild(im); }
u.appendChild(document.createTextNode(text));
history.push({role: "user", content});
image = null; $("att").textContent = ""; $("file").value = "";
const b = bubble("bot", ""); const det = document.createElement("details"); const sum = document.createElement("summary");
sum.textContent = "thinking"; det.appendChild(sum); const th = document.createElement("div"); det.appendChild(th);
const ans = document.createElement("div"); b.appendChild(ans);
$("send").disabled = true; $("stop").disabled = false; ctrl = new AbortController();
let answer = "";
try {
const headers = {"Content-Type": "application/json"};
if ($("key").value) headers["Authorization"] = "Bearer " + $("key").value;
const body = {model: "strata", messages: history, stream: true, max_tokens: 8192};
body.reasoning_effort = $("think").value;
const r = await fetch("/v1/chat/completions", {method: "POST", headers, body: JSON.stringify(body), signal: ctrl.signal});
if (!r.ok) { ans.textContent = "error: " + ((await r.json()).error || {}).message; throw 0; }
const rd = r.body.getReader(), dec = new TextDecoder(); let buf = "";
for (;;) {
const {value, done} = await rd.read(); if (done) break;
buf += dec.decode(value, {stream: true});
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 2);
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
const d = JSON.parse(line.slice(6)).choices[0].delta;
if (d.reasoning_content) { if (!det.parentNode) b.insertBefore(det, ans); th.textContent += d.reasoning_content; }
if (d.content) { answer += d.content; ans.textContent = answer; }
$("log").scrollTop = $("log").scrollHeight;
}
}
} catch (err) { if (err && err.name === "AbortError") ans.textContent = answer + " [stopped]"; }
history.push({role: "assistant", content: answer});
$("send").disabled = false; $("stop").disabled = true; ctrl = null;
};
</script>
</body>
</html>
+863 -59
View File
File diff suppressed because it is too large. Load diff
+246
View File
@@ -0,0 +1,246 @@
"""serve/telemetry.py - hardware readings for the web app's Monitor tab (idea from PR #22 by code-martin).
A background thread samples once a second and keeps the last 60 readings of each series for the sparklines:
- GPU: NVIDIA's own NVML library (nvml.dll / libnvidia-ml.so.1, installed with every driver) through ctypes, so no
pip package is needed: load, VRAM, temperature, power, PCIe link and throughput.
- CPU, RAM, disk: `psutil` when it is installed (setup installs it); without it the CPU and RAM readings fall back to
the OS (Windows GlobalMemoryStatusEx / GetSystemTimes, Linux /proc) and the disk rate is absent.
Anything that cannot be read is None; nothing here can stop the server.
"""
from __future__ import annotations
import collections
import ctypes
import os
import platform
import sys
import threading
import time
HISTORY = 60
# ------------------------------------------------------------------------------------------------ NVML
class _Nvml:
class Util(ctypes.Structure):
_fields_ = [("gpu", ctypes.c_uint), ("memory", ctypes.c_uint)]
class Mem(ctypes.Structure):
_fields_ = [("total", ctypes.c_ulonglong), ("free", ctypes.c_ulonglong), ("used", ctypes.c_ulonglong)]
def __init__(self, index=0):
self.lib = self.dev = None
names = ["nvml.dll", os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"),
"NVIDIA Corporation", "NVSMI", "nvml.dll")] if os.name == "nt" \
else ["libnvidia-ml.so.1", "libnvidia-ml.so"]
for n in names:
try:
self.lib = ctypes.CDLL(n)
break
except OSError:
continue
if self.lib is None:
return
try:
init = getattr(self.lib, "nvmlInit_v2", None) or self.lib.nvmlInit
if init() != 0:
self.lib = None
return
h = ctypes.c_void_p()
get = getattr(self.lib, "nvmlDeviceGetHandleByIndex_v2", None) or self.lib.nvmlDeviceGetHandleByIndex
if get(ctypes.c_uint(index), ctypes.byref(h)) != 0:
self.lib = None
return
self.dev = h
except (AttributeError, OSError):
self.lib = None
def ok(self):
return self.lib is not None and self.dev is not None
def _uint(self, fn, *args):
v = ctypes.c_uint()
try:
return v.value if getattr(self.lib, fn)(self.dev, *args, ctypes.byref(v)) == 0 else None
except (AttributeError, OSError):
return None
def name(self):
buf = ctypes.create_string_buffer(96)
try:
if self.lib.nvmlDeviceGetName(self.dev, buf, ctypes.c_uint(96)) == 0:
return buf.value.decode(errors="replace")
except (AttributeError, OSError):
pass
return None
def read(self):
out = {}
u = self.Util()
try:
if self.lib.nvmlDeviceGetUtilizationRates(self.dev, ctypes.byref(u)) == 0:
out["util"] = u.gpu
except (AttributeError, OSError):
pass
m = self.Mem()
try:
if self.lib.nvmlDeviceGetMemoryInfo(self.dev, ctypes.byref(m)) == 0:
out["mem_used"], out["mem_total"] = m.used, m.total
except (AttributeError, OSError):
pass
out["temp"] = self._uint("nvmlDeviceGetTemperature", ctypes.c_uint(0)) # NVML_TEMPERATURE_GPU
mw = self._uint("nvmlDeviceGetPowerUsage")
out["power"] = mw / 1000.0 if mw is not None else None
lim = self._uint("nvmlDeviceGetEnforcedPowerLimit")
out["power_limit"] = lim / 1000.0 if lim is not None else None
out["pcie_gen"] = self._uint("nvmlDeviceGetCurrPcieLinkGeneration") # drops at idle (power saving)
out["pcie_gen_max"] = self._uint("nvmlDeviceGetMaxPcieLinkGeneration")
out["pcie_width"] = self._uint("nvmlDeviceGetCurrPcieLinkWidth")
rx = self._uint("nvmlDeviceGetPcieThroughput", ctypes.c_uint(1)) # NVML_PCIE_UTIL_RX_BYTES, KB/s
tx = self._uint("nvmlDeviceGetPcieThroughput", ctypes.c_uint(0))
out["pcie_rx_mb"] = rx / 1024.0 if rx is not None else None
out["pcie_tx_mb"] = tx / 1024.0 if tx is not None else None
return out
# ------------------------------------------------------------------------------------------------ CPU / RAM
def _cpu_name():
if os.name == "nt":
try:
import winreg
k = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0")
return winreg.QueryValueEx(k, "ProcessorNameString")[0].strip()
except OSError:
pass
elif os.path.exists("/proc/cpuinfo"):
for line in open("/proc/cpuinfo", encoding="utf-8", errors="replace"):
if line.startswith("model name"):
return line.split(":", 1)[1].strip()
return platform.processor() or None
class _CpuRamFallback:
"""CPU load and RAM without psutil."""
def __init__(self):
self.prev = self._times()
def _times(self):
if os.name == "nt":
idle, kern, user = (ctypes.c_ulonglong() for _ in range(3))
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kern), ctypes.byref(user)):
return idle.value, kern.value + user.value # kernel time includes idle
return None
try:
f = [int(x) for x in open("/proc/stat").readline().split()[1:]]
return f[3] + f[4], sum(f)
except (OSError, ValueError):
return None
def cpu(self):
cur = self._times()
prev, self.prev = self.prev, cur
if not cur or not prev or cur[1] == prev[1]:
return None
return max(0.0, min(100.0, 100.0 * (1 - (cur[0] - prev[0]) / (cur[1] - prev[1]))))
@staticmethod
def ram():
if os.name == "nt":
class MS(ctypes.Structure):
_fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
m = MS()
m.dwLength = ctypes.sizeof(MS)
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)):
return m.ullTotalPhys - m.ullAvailPhys, m.ullTotalPhys
return None, None
try:
info = dict(line.split(":", 1) for line in open("/proc/meminfo"))
total = int(info["MemTotal"].split()[0]) * 1024
avail = int(info["MemAvailable"].split()[0]) * 1024
return total - avail, total
except (OSError, KeyError, ValueError):
return None, None
# ------------------------------------------------------------------------------------------------ the sampler
class Telemetry:
def __init__(self, extra=None):
"""`extra()` -> dict of more series to record each second (the server's tok/s)."""
self.extra = extra
self.lock = threading.Lock()
self.now: dict = {}
self.hist = collections.defaultdict(lambda: collections.deque(maxlen=HISTORY))
self.gpu = _Nvml()
try:
import psutil # noqa: F401
self.ps = sys.modules["psutil"]
except ImportError:
self.ps = None
self.fallback = _CpuRamFallback()
self.static = {
"gpu_name": self.gpu.name() if self.gpu.ok() else None,
"cpu_name": _cpu_name(),
"cores": (self.ps.cpu_count(logical=False) if self.ps else None) or None,
"threads": os.cpu_count(),
"psutil": self.ps is not None,
}
self._disk_prev = None
threading.Thread(target=self._loop, daemon=True).start()
def _disk(self):
if not self.ps:
return None, None
try:
c = self.ps.disk_io_counters()
except (OSError, RuntimeError):
return None, None
t = time.time()
prev, self._disk_prev = self._disk_prev, (t, c.read_bytes, c.write_bytes)
if prev is None or t <= prev[0]:
return None, None
dt = t - prev[0]
return (c.read_bytes - prev[1]) / dt / 2**20, (c.write_bytes - prev[2]) / dt / 2**20
def sample(self):
s = {}
if self.gpu.ok():
g = self.gpu.read()
s.update({f"gpu_{k}": v for k, v in g.items()})
if self.ps:
try:
s["cpu"] = self.ps.cpu_percent(interval=None)
vm = self.ps.virtual_memory()
s["ram_used"], s["ram_total"] = vm.total - vm.available, vm.total
except (OSError, RuntimeError):
pass
else:
s["cpu"] = self.fallback.cpu()
s["ram_used"], s["ram_total"] = self.fallback.ram()
s["disk_read_mb"], s["disk_write_mb"] = self._disk()
if self.extra:
try:
s.update(self.extra())
except Exception: # noqa: BLE001 - telemetry must never take the server down
pass
return s
def _loop(self):
while True:
s = self.sample()
with self.lock:
self.now = s
for k in ("gpu_util", "gpu_mem_used", "gpu_temp", "gpu_power", "gpu_pcie_rx_mb", "cpu", "ram_used",
"disk_read_mb", "tok_s"):
v = s.get(k)
self.hist[k].append(round(v, 2) if isinstance(v, float) else v)
time.sleep(1.0)
def snapshot(self):
with self.lock:
return {"now": dict(self.now), "history": {k: list(v) for k, v in self.hist.items()},
"static": dict(self.static)}
+412
View File
@@ -0,0 +1,412 @@
"""serve/test_server.py - the max tokens budget over both APIs, against the mock engine (no GPU, no pack).
python -m unittest serve.test_server -v
"""
from __future__ import annotations
import json
import os
import sys
import unittest
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from serve.frontend import ChatTemplate # noqa: E402
from serve.server import CTX_SLACK, ByteTokenizer, EngineDied, MockEngine, Service, serve # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
CTX = 4096
ANSWER = "x" * 2000 # longer than the old 1024 fallback: one token per byte
class RecordingEngine(MockEngine):
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
self.last_max_new = max_new
yield from super().generate(ids, max_new, sampling, cancel, embeddings)
class MaxTokens(unittest.TestCase):
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.engine = RecordingEngine(tok, "</think>\n\n" + ANSWER, max_context=CTX)
cls.svc = Service(cls.engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
cls.httpd = serve(cls.svc, port=0)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
def post(self, path, body):
req = urllib.request.Request(self.base + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
with e:
return e.code, json.loads(e.read())
def call(self, api, text="hi", **budget):
"""-> (status, body, prompt tokens, completion tokens); `budget` is merged into the request as given."""
msgs = [{"role": "user", "content": text}]
if api == "openai":
s, b = self.post("/v1/chat/completions", {"model": "m", "messages": msgs, **budget})
u = b.get("usage", {})
return s, b, u.get("prompt_tokens"), u.get("completion_tokens")
s, b = self.post("/v1/messages", {"model": "m", "messages": msgs, **budget})
u = b.get("usage", {})
return s, b, u.get("input_tokens"), u.get("output_tokens")
def test_unset_budget_is_the_rest_of_the_context(self):
cases = {"openai": [{"max_tokens": -1}, {"max_tokens": 0}, {}, {"max_tokens": None},
{"max_completion_tokens": -1}, {"max_completion_tokens": None, "max_tokens": None}],
"anthropic": [{"max_tokens": -1}, {"max_tokens": 0}, {}, {"max_tokens": None}]}
for api, budgets in cases.items():
for budget in budgets:
with self.subTest(api=api, budget=budget):
s, b, pt, ct = self.call(api, **budget)
self.assertEqual(s, 200, b)
self.assertEqual(self.engine.last_max_new, CTX - CTX_SLACK - pt)
self.assertGreater(ct, 1024) # the whole answer, not cut at the old 1024 fallback
def test_explicit_budget_is_honoured(self):
for api, budget in [("openai", {"max_tokens": 50}), ("openai", {"max_completion_tokens": 50}),
("openai", {"max_completion_tokens": 50, "max_tokens": 9}),
("anthropic", {"max_tokens": 50}), ("openai", {"max_tokens": 1500}),
("anthropic", {"max_tokens": 1500})]:
with self.subTest(api=api, budget=budget):
want = budget.get("max_completion_tokens") or budget["max_tokens"]
s, b, _, ct = self.call(api, **budget)
self.assertEqual(s, 200, b)
self.assertEqual(self.engine.last_max_new, want)
self.assertEqual(ct, want)
def test_explicit_budget_over_the_context_is_rejected(self):
for api in ("openai", "anthropic"):
with self.subTest(api=api):
s, b, _, _ = self.call(api, max_tokens=CTX)
self.assertEqual(s, 400)
self.assertIn("exceeds the context", b["error"]["message"])
def test_unset_budget_with_a_near_full_prompt(self):
_, _, pt0, _ = self.call("openai", max_tokens=1)
overhead = pt0 - len("hi") # the template's tokens around the user text
for api in ("openai", "anthropic"):
_, _, pa, _ = self.call(api, max_tokens=1)
over = pa - pt0 # the Anthropic template may differ slightly
with self.subTest(api=api, room=5): # a few tokens left: the budget is exactly those
text = "y" * (CTX - CTX_SLACK - overhead - over - 5)
s, b, pt, ct = self.call(api, text=text, max_tokens=-1)
self.assertEqual(s, 200, b)
self.assertEqual(self.engine.last_max_new, 5)
self.assertEqual(ct, 5)
with self.subTest(api=api, room=0): # nothing left: rejected, not truncated
text = "y" * (CTX - CTX_SLACK - overhead - over)
s, b, _, _ = self.call(api, text=text)
self.assertEqual(s, 400, b)
self.assertIn("no room to answer", b["error"]["message"])
def test_debug_log_shows_the_resolved_budget(self):
import contextlib
import io
os.environ["STRATA_DEBUG"] = "1"
try:
for api in ("openai", "anthropic"):
with self.subTest(api=api):
out = io.StringIO()
with contextlib.redirect_stdout(out):
_, _, pt, _ = self.call(api, max_tokens=-1)
self.assertIn(f"max_new={CTX - CTX_SLACK - pt} ", out.getvalue())
finally:
del os.environ["STRATA_DEBUG"]
class FitMaxTokens(unittest.TestCase):
"""PR #24: --fit-max-tokens clamps an explicit budget that overshoots the context instead of a 400."""
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.engine = RecordingEngine(tok, "</think>\n\n" + ANSWER, max_context=CTX)
cls.svc = Service(cls.engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"), fit_max_tokens=True)
cls.httpd = serve(cls.svc, port=0)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
post = MaxTokens.post
call = MaxTokens.call
def test_overshoot_is_clamped_to_the_room(self):
for api in ("openai", "anthropic"):
with self.subTest(api=api):
s, b, pt, ct = self.call(api, max_tokens=CTX)
self.assertEqual(s, 200, b)
self.assertEqual(self.engine.last_max_new, CTX - CTX_SLACK - pt)
def test_a_budget_that_fits_is_unchanged(self):
s, b, _, ct = self.call("openai", max_tokens=50)
self.assertEqual(s, 200, b)
self.assertEqual(self.engine.last_max_new, 50)
def test_no_room_is_still_a_400(self):
_, _, pt0, _ = self.call("openai", max_tokens=1)
overhead = pt0 - len("hi")
s, b, _, _ = self.call("openai", text="y" * (CTX - CTX_SLACK - overhead), max_tokens=100)
self.assertEqual(s, 400, b)
self.assertIn("no room to answer", b["error"]["message"])
class DyingEngine(MockEngine):
"""Issue #27: an engine that dies after a few tokens of its first answer, and comes back when restarted."""
def __init__(self, tok, script, max_context):
super().__init__(tok, script, max_context=max_context)
self.dead, self.restarts, self.die_after = False, 0, 5
def alive(self):
return not self.dead
def restart(self):
self.dead, self.die_after = False, None
self.restarts += 1
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
for i, t in enumerate(super().generate(ids, max_new, sampling, cancel, embeddings)):
if self.die_after is not None and i == self.die_after:
self.dead = True
raise EngineDied("the engine stopped unexpectedly (exit code -9)")
yield t
class EngineDeath(unittest.TestCase):
"""Issue #27: a dead engine is an error (not "length"), and the next request starts it again."""
def test_error_then_restart(self):
tok = ByteTokenizer()
eng = DyingEngine(tok, "</think>\n\n" + ANSWER, max_context=CTX)
svc = Service(eng, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
def post(body):
req = urllib.request.Request(base + "/v1/chat/completions", data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
with e:
return e.code, e.read().decode()
msgs = [{"role": "user", "content": "hi"}]
code, text = post({"model": "m", "messages": msgs, "max_tokens": 50, "stream": True})
self.assertEqual(code, 200)
self.assertIn('"error"', text)
self.assertIn("stopped unexpectedly", text)
self.assertTrue(text.rstrip().endswith("data: [DONE]"))
self.assertEqual(svc.metrics()["requests"][0]["finish"], "error")
code, text = post({"model": "m", "messages": msgs, "max_tokens": 50})
self.assertEqual(code, 200, text)
self.assertEqual(eng.restarts, 1)
self.assertEqual(json.loads(text)["usage"]["completion_tokens"], 50)
finally:
httpd.shutdown()
httpd.server_close()
def test_engine_err_mid_stream(self):
"""The engine's ERR line after the stream started reaches the client as an error event (it used to be a
400 written into the open stream, which clients read as an empty answer)."""
class ErrEngine(MockEngine):
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
yield None # a prompt-progress heartbeat: the stream has started
raise ValueError("verify: layer 31 never rang (an illegal memory access was encountered)")
tok = ByteTokenizer()
svc = Service(ErrEngine(tok, ANSWER, max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
for path, body in [("/v1/chat/completions", {"model": "m", "stream": True, "max_tokens": 20,
"messages": [{"role": "user", "content": "hi"}]}),
("/v1/messages", {"model": "m", "stream": True, "max_tokens": 20,
"messages": [{"role": "user", "content": "hi"}]})]:
req = urllib.request.Request(base + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
text = r.read().decode()
self.assertIn("illegal memory access", text, path)
self.assertNotIn("HTTP/1", text, path)
self.assertEqual(svc.metrics()["requests"][0]["finish"], "error")
finally:
httpd.shutdown()
httpd.server_close()
class SharedSettings(unittest.TestCase):
"""The web app's "Use for other apps too": POST /settings makes its Chat settings every client's defaults."""
@classmethod
def setUpClass(cls):
import tempfile
class Sampled(RecordingEngine):
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
self.last_sampling = dict(sampling or {})
yield from super().generate(ids, max_new, sampling, cancel, embeddings)
tok = ByteTokenizer()
cls.engine = Sampled(tok, "</think>\n\nhello", max_context=CTX)
cls.svc = Service(cls.engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
cls.tmp = tempfile.TemporaryDirectory()
cls.svc.shared_path = os.path.join(cls.tmp.name, "strata-x.shared-settings.json")
cls.httpd = serve(cls.svc, port=0)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.tmp.cleanup()
def req(self, path, body, headers=None, raw=None):
h = {"Content-Type": "application/json", **(headers or {})}
r = urllib.request.Request(self.base + path, data=raw if raw is not None else json.dumps(body).encode(), headers=h)
try:
with urllib.request.urlopen(r, timeout=30) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
with e:
return e.code, json.loads(e.read())
def chat(self, **extra):
return self.req("/v1/chat/completions", {"model": "m", "messages": [{"role": "user", "content": "hi"}], **extra})
def tearDown(self):
self.svc.set_shared(None)
def test_other_apps_get_the_chat_settings(self):
d = {"temperature": 0.3, "top_p": 0.9, "top_k": 10, "seed": 7, "max_tokens": 77,
"reasoning_effort": "low", "experimental_speed_projection": False}
code, b = self.req("/settings", {"defaults": d})
self.assertEqual(code, 200, b)
self.assertTrue(b["shared"])
self.assertTrue(os.path.exists(self.svc.shared_path))
code, _ = self.chat() # a client that sets nothing
self.assertEqual(code, 200)
got = self.engine.last_sampling
for k in ("temperature", "top_p", "top_k", "seed", "experimental_speed_projection"):
self.assertEqual(got[k], d[k], k)
self.assertEqual(self.engine.last_max_new, 77)
code, _ = self.chat(temperature=0.9, max_tokens=5) # its own values win
self.assertEqual(self.engine.last_sampling["temperature"], 0.9)
self.assertEqual(self.engine.last_max_new, 5)
r = self.svc.with_shared({"messages": []}, "openai")
self.assertEqual(r["reasoning_effort"], "low")
self.assertEqual(self.svc.with_shared({"reasoning_effort": "high"}, "openai")["reasoning_effort"], "high")
self.assertEqual(self.svc.with_shared({}, "anthropic")["output_config"], {"effort": "low"})
def test_off_again(self):
self.req("/settings", {"defaults": {"temperature": 0.3}})
code, b = self.req("/settings", {"defaults": None})
self.assertEqual((code, b["shared"]), (200, False))
self.assertFalse(os.path.exists(self.svc.shared_path))
self.chat()
self.assertNotIn("temperature", self.engine.last_sampling)
def test_only_strata_s_own_page_may_set_them(self):
code, _ = self.req("/settings", None, {"Content-Type": "text/plain"}, raw=b'{"defaults": {"temperature": 1}}')
self.assertEqual(code, 415)
code, _ = self.req("/settings", {"defaults": {"temperature": 1}}, {"Origin": "http://evil.example"})
self.assertEqual(code, 403)
code, b = self.req("/settings", {"defaults": {"temperature": 9}})
self.assertEqual(code, 400)
self.assertIn("temperature", b["error"]["message"])
self.assertEqual(self.svc.shared, {})
host = self.base.split("://", 1)[1]
code, _ = self.req("/settings", {"defaults": {"temperature": 1}}, {"Origin": "http://" + host})
self.assertEqual(code, 200)
def test_they_need_the_key_when_one_is_set(self):
self.svc.api_key = "secret"
try:
self.assertEqual(self.req("/settings", {"defaults": {"temperature": 1}})[0], 401)
self.assertEqual(self.req("/settings", {"defaults": {"temperature": 1}},
{"Authorization": "Bearer secret"})[0], 200)
finally:
self.svc.api_key = ""
class WebApp(unittest.TestCase):
"""The web app (PR #22's dashboard idea, rebuilt): its page and files, and GET /metrics."""
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.svc = Service(RecordingEngine(tok, "</think>\n\nhello", max_context=CTX), tok,
ChatTemplate(ROOT / "serve/chat_template.jinja"))
cls.httpd = serve(cls.svc, port=0)
cls.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
def get(self, path, headers=None):
req = urllib.request.Request(self.base + path, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, r.headers.get("Content-Type", ""), r.read()
except urllib.error.HTTPError as e:
return e.code, e.headers.get("Content-Type", ""), e.read()
def test_page_and_files(self):
code, ctype, body = self.get("/")
self.assertEqual(code, 200)
self.assertIn("text/html", ctype)
self.assertIn(b"/web/app.js", body)
for path, want in (("/web/app.js", "javascript"), ("/web/app.css", "text/css"), ("/web/tokens.css", "text/css"),
("/web/components.css", "text/css"), ("/web/sprite.svg", "image/svg+xml")):
with self.subTest(path=path):
code, ctype, _ = self.get(path)
self.assertEqual(code, 200)
self.assertIn(want, ctype)
def test_only_the_app_files_are_served(self):
for path in ("/web/..%2Fserver.py", "/web/index.html", "/web/test.py", "/fonts/..%2F..%2Fsetup.py",
"/fonts/missing.woff2", "/fonts/x.ttf"):
with self.subTest(path=path):
self.assertEqual(self.get(path)[0], 404)
def test_metrics(self):
data = json.dumps({"model": "m", "messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}).encode()
urllib.request.urlopen(urllib.request.Request(self.base + "/v1/chat/completions", data=data,
headers={"Content-Type": "application/json"}), timeout=10).read()
code, ctype, body = self.get("/metrics")
self.assertEqual(code, 200)
m = json.loads(body)
for key in ("engine", "live", "requests", "hardware", "hardware_static", "history"):
self.assertIn(key, m)
self.assertEqual(m["engine"]["max_context"], CTX)
self.assertEqual(m["live"]["state"], "idle")
self.assertEqual(m["requests"][0]["output_tokens"], 5)
def test_metrics_need_the_key_when_one_is_set(self):
self.svc.api_key = "secret"
try:
self.assertEqual(self.get("/metrics")[0], 401)
self.assertEqual(self.get("/metrics", {"Authorization": "Bearer secret"})[0], 200)
self.assertEqual(self.get("/")[0], 200) # the page itself asks for the key
finally:
self.svc.api_key = ""
if __name__ == "__main__":
unittest.main()
+146
View File
@@ -0,0 +1,146 @@
/* Strata web app - layout on top of tokens.css + components.css (the Strata UI kit). Colours, type, radii and
shadows come only from the kit's tokens. */
[hidden] { display: none !important; }
html, body { height: 100%; }
body { display: flex; flex-direction: column; overflow: hidden; }
main { flex: 1; min-height: 0; display: flex; }
.view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.view[hidden] { display: none; }
.muted { color: var(--st-ink-muted); }
.small { font-size: var(--st-fs-xs); }
.card-title { font-weight: var(--st-fw-bold); font-size: var(--st-fs-base); color: var(--st-ink); }
.st-header { position: relative; z-index: 2; flex: none; }
.st-header .st-tabs { margin-left: var(--st-s-4); }
#drawer-close .st-icon { transform: rotate(-90deg); }
/* ---------------------------------------------------------------- chat */
.chat-scroll { flex: 1; min-height: 0; overflow-y: auto; }
.chat { max-width: 860px; margin: 0 auto; padding: var(--st-s-8) var(--st-s-6) var(--st-s-6); display: flex;
flex-direction: column; gap: var(--st-s-6); }
.chat-empty { margin: 12vh auto 0; text-align: center; }
.chat-empty__title { font-weight: var(--st-fw-black); font-size: var(--st-fs-display); line-height: var(--st-lh-tight);
letter-spacing: -.02em; }
.chat-empty__sub { margin-top: var(--st-s-3); color: var(--st-ink-muted); }
.st-msg { width: 100%; }
.st-msg--assistant { align-self: flex-start; }
.st-msg--assistant .st-bubble { width: 100%; }
.st-msg--user .st-bubble { white-space: pre-wrap; overflow-wrap: anywhere; }
.st-bubble > :first-child { margin-top: 0; }
.st-bubble > :last-child { margin-bottom: 0; }
.st-bubble p, .st-bubble ul, .st-bubble ol { margin: 0 0 var(--st-s-3); }
.st-bubble ul, .st-bubble ol { padding-left: var(--st-s-6); }
.st-bubble li + li { margin-top: var(--st-s-1); }
.st-bubble h3 { font-size: var(--st-fs-lg); margin: var(--st-s-4) 0 var(--st-s-2); color: var(--st-ink); }
.st-bubble h4 { font-size: var(--st-fs-base); margin: var(--st-s-3) 0 var(--st-s-2); color: var(--st-ink); }
.st-bubble code.inline { font: 400 .9em var(--st-font-mono); background: var(--st-surface-2); border: 1px solid var(--st-line-soft);
border-radius: 6px; padding: 1px 5px; }
.st-bubble .st-code { margin: var(--st-s-3) 0; }
.st-bubble blockquote { margin: 0 0 var(--st-s-3); padding-left: var(--st-s-3); border-left: 3px solid var(--st-line);
color: var(--st-ink-muted); }
.st-bubble hr { border: 0; border-top: 1px solid var(--st-line); margin: var(--st-s-4) 0; }
.st-bubble table { border-collapse: collapse; margin: 0 0 var(--st-s-3); font-size: var(--st-fs-md); }
.st-bubble th, .st-bubble td { border: 1px solid var(--st-line); padding: 6px 10px; text-align: left; }
.msg-images { display: flex; flex-wrap: wrap; gap: var(--st-s-2); justify-content: flex-end; }
.msg-images img { max-width: 220px; max-height: 180px; border-radius: var(--st-r-md); border: 1px solid var(--st-line); }
.st-msg .st-collapse { width: 100%; }
.st-collapse__body.thinking { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow-y: auto; }
.st-msg__meta .st-btn--icon { width: 28px; height: 28px; }
.st-msg__meta .st-btn--icon .st-icon { width: 16px; height: 16px; }
.cursor::after { content: ""; display: inline-block; width: 8px; height: 1.1em; margin-left: 2px; vertical-align: -3px;
border-radius: 2px; background: var(--st-accent); animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.msg-error { color: var(--st-danger-text); background: var(--st-danger-tint); border-radius: var(--st-r-md); padding: 10px 14px;
font-size: var(--st-fs-md); }
.composer-wrap { flex: none; padding: 0 var(--st-s-6) var(--st-s-6); }
.st-composer { max-width: 860px; margin: 0 auto; }
.st-composer textarea { max-height: 40vh; }
.st-btn--send { width: 42px; padding: 0; }
.composer-hint { font-size: var(--st-fs-xs); color: var(--st-ink-muted); margin-right: var(--st-s-2); font-variant-numeric: tabular-nums; }
.attachments { display: flex; flex-wrap: wrap; gap: var(--st-s-2); padding: 2px 4px 0; }
.st-composer.dragging { outline: 2px dashed var(--st-accent); outline-offset: 3px; }
.chip { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 10px; border-radius: var(--st-r-pill);
background: var(--st-surface-2); border: 1px solid var(--st-line); font-size: var(--st-fs-xs); color: var(--st-ink-soft); }
.chip .st-btn--icon { width: 22px; height: 22px; border-radius: 50%; }
.chip .st-btn--icon .st-icon { width: 14px; height: 14px; }
/* ---------------------------------------------------------------- monitor */
.view--monitor, .view--about { overflow-y: auto; }
.monitor, .about { max-width: 1240px; width: 100%; margin: 0 auto; padding: var(--st-s-6); display: flex; flex-direction: column;
gap: var(--st-s-5); }
.state-card { display: flex; flex-direction: column; gap: var(--st-s-3); }
.state-card__head { display: flex; align-items: center; gap: var(--st-s-4); flex-wrap: wrap; }
.badges { display: flex; gap: var(--st-s-2); flex-wrap: wrap; }
.badges .st-badge { opacity: .38; transition: opacity var(--st-dur); }
.badges .st-badge.on { opacity: 1; }
.state-card__row { display: flex; justify-content: space-between; gap: var(--st-s-4); font-size: var(--st-fs-sm);
font-weight: var(--st-fw-medium); font-variant-numeric: tabular-nums; }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--st-s-4); }
.metric-card { padding: var(--st-s-4) var(--st-s-5); }
.st-metric__value small { font-size: var(--st-fs-md); font-weight: var(--st-fw-medium); color: var(--st-ink-muted); margin-left: 4px; }
.st-metric__sub { font-size: var(--st-fs-xs); color: var(--st-ink-muted); font-variant-numeric: tabular-nums; min-height: 1em; }
.st-metric__spark { display: block; overflow: visible; }
.st-metric__spark[data-tone="warn"] { color: var(--st-warn); }
.st-metric__spark[data-tone="info"] { color: var(--st-info); }
.monitor-row { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.4fr); gap: var(--st-s-4); }
.ctx-card { display: flex; flex-direction: column; gap: var(--st-s-2); }
.ctx-gauge { position: relative; width: 150px; height: 150px; margin: var(--st-s-2) auto var(--st-s-3); }
.ctx-gauge .st-gauge { width: 150px; height: 150px; }
.ctx-gauge .st-gauge__fill { transition: stroke-dasharray 400ms var(--st-ease); }
.ctx-gauge__label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
font-size: var(--st-fs-xs); }
.ctx-gauge__pct { font-weight: var(--st-fw-black); font-size: var(--st-fs-xl); line-height: 1.1; font-variant-numeric: tabular-nums; }
.bar-row { display: flex; justify-content: space-between; font-size: var(--st-fs-sm); font-weight: var(--st-fw-medium);
margin-top: var(--st-s-2); font-variant-numeric: tabular-nums; }
.req-card { display: flex; flex-direction: column; gap: var(--st-s-3); min-width: 0; }
.table-wrap { overflow-x: auto; }
.req-head { display: flex; align-items: center; justify-content: space-between; gap: var(--st-s-2); }
.table-wrap.all { max-height: 420px; overflow-y: auto; }
.req-totals { margin: 0; font-size: var(--st-fs-sm); }
.req-totals:empty { display: none; }
/* ---------------------------------------------------------------- about */
.about { max-width: 860px; }
.about .st-card { display: flex; flex-direction: column; gap: var(--st-s-3); }
.about p { margin: 0; }
.facts { display: grid; grid-template-columns: minmax(140px, max-content) 1fr; gap: 10px var(--st-s-6); margin: 0; font-size: var(--st-fs-md); }
.facts dt { color: var(--st-ink-muted); }
.facts dd { margin: 0; color: var(--st-ink-soft); font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 6px;
overflow-wrap: anywhere; }
.facts dd code { font: 400 13px var(--st-font-mono); }
.facts .st-btn--icon { width: 28px; height: 28px; }
.facts .st-btn--icon .st-icon { width: 16px; height: 16px; }
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--st-s-4); font-size: var(--st-fs-sm);
font-weight: var(--st-fw-medium); color: var(--st-ink-soft); }
.toggle-row .st-toggle { flex: none; } /* a long label must not squeeze the switch */
/* ---------------------------------------------------------------- drawer */
.st-drawer { z-index: 40; }
.scrim { position: fixed; inset: 0; background: rgba(15, 23, 42, .18); z-index: 39; }
.seg { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; padding: 4px; background: var(--st-surface-2);
border: 1px solid var(--st-line); border-radius: var(--st-r-md); }
.seg button { height: 32px; border: 0; border-radius: 10px; background: transparent; color: var(--st-ink-muted);
font: 500 var(--st-fs-sm)/1 var(--st-font); cursor: pointer; }
.seg button[aria-checked="true"] { background: var(--st-surface); color: var(--st-ink); font-weight: var(--st-fw-bold);
box-shadow: var(--st-sh-card); }
.drawer-actions { display: grid; grid-template-columns: 1fr 1fr; gap: var(--st-s-3); margin-top: var(--st-s-2); }
.st-toast { animation: toast-in 180ms var(--st-ease); }
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } }
/* ---------------------------------------------------------------- narrow screens */
@media (max-width: 1000px) {
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.monitor-row { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.st-header { padding: 0 var(--st-s-3); gap: var(--st-s-2); }
.st-header .st-tabs { margin-left: 0; }
.st-tab { padding: 0 10px; }
.st-tab .st-icon { display: none; }
#pill-text { display: none; }
.chat { padding: var(--st-s-5) var(--st-s-4); }
.composer-wrap { padding: 0 var(--st-s-3) var(--st-s-3); }
.monitor, .about { padding: var(--st-s-4); }
.facts { grid-template-columns: 1fr; gap: 2px; }
.facts dd { margin-bottom: var(--st-s-2); }
}
+829
View File
@@ -0,0 +1,829 @@
// serve/web/app.js - the Strata web app: Chat, Monitor, About. No framework, no network beyond this server.
// The Monitor tab rebuilds PR #22's dashboard idea (code-martin) on the server's own /metrics.
"use strict";
const $ = (id) => document.getElementById(id);
const SPRITE = "/web/sprite.svg";
const icon = (name, cls = "st-icon") => `<svg class="${cls}" aria-hidden="true"><use href="${SPRITE}#i-${name}"/></svg>`;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
const fmt = (n, d = 0) => (n == null || Number.isNaN(n) ? "–" : Number(n).toLocaleString(undefined, {maximumFractionDigits: d, minimumFractionDigits: d}));
const kfmt = (n) => (n == null ? "–" : n >= 1000 ? `${fmt(n / 1000, n >= 10000 ? 0 : 1)}k` : fmt(n));
// a context size: 32768 -> "32K" (powers of two), else like kfmt
const ctxfmt = (n) => (n && n % 1024 === 0 ? `${fmt(n / 1024)}K` : kfmt(n));
const gb = (b, d = 1) => (b == null ? "–" : fmt(b / 1073741824, d)); // memory: binary GB, as Windows shows it
const store = {
get(k, d) { try { const v = localStorage.getItem("strata." + k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } },
set(k, v) { try { localStorage.setItem("strata." + k, JSON.stringify(v)); } catch (e) { /* private mode: in memory only */ } },
};
// ------------------------------------------------------------------ toasts
function toast(kind, title, text = "", ms = 3500, action = null) {
const names = {info: "info", success: "check", warn: "warning", error: "error"};
const el = document.createElement("div");
el.className = `st-toast st-toast--${kind}`;
el.innerHTML = `${icon(names[kind] || "info")}<div><div class="st-toast__title"></div><div class="t-text"></div></div>`;
el.querySelector(".st-toast__title").textContent = title;
el.querySelector(".t-text").textContent = text;
if (action) {
const b = document.createElement("button");
b.className = "st-btn st-btn--secondary";
b.style.height = "32px";
b.style.marginLeft = "auto";
b.textContent = action.label;
b.onclick = () => { action.run(); el.remove(); };
el.appendChild(b);
}
$("toasts").appendChild(el);
setTimeout(() => el.remove(), ms);
}
async function copyText(text, btn) {
try {
await navigator.clipboard.writeText(text);
} catch (e) { // http on another host: no async clipboard
const ta = document.createElement("textarea");
ta.value = text; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove();
}
if (btn) {
const use = btn.querySelector("use");
use.setAttribute("href", `${SPRITE}#i-check`);
setTimeout(() => use.setAttribute("href", `${SPRITE}#i-copy`), 1500);
}
toast("success", "Copied to clipboard", "", 1800);
}
// ------------------------------------------------------------------ theme and tabs
// the system's theme until the user picks one (only a click is saved)
function setTheme(t, save) {
document.documentElement.dataset.theme = t;
if (save) try { localStorage.setItem("strata.theme", t); } catch (e) { /* ignore */ }
$("theme-icon").setAttribute("href", `${SPRITE}#i-${t === "dark" ? "sun" : "moon"}`);
$("dark-toggle").setAttribute("aria-checked", String(t === "dark"));
}
const flipTheme = () => setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
$("theme-btn").onclick = flipTheme;
$("dark-toggle").onclick = flipTheme;
setTheme(document.documentElement.dataset.theme || "light", false);
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
let saved = null;
try { saved = localStorage.getItem("strata.theme"); } catch (err) { /* ignore */ }
if (!saved) setTheme(e.matches ? "dark" : "light", false);
});
let tab = "chat";
function showTab(name) {
tab = ["chat", "monitor", "about"].includes(name) ? name : "chat";
for (const b of document.querySelectorAll(".st-tab")) b.setAttribute("aria-selected", String(b.dataset.tab === tab));
for (const v of ["chat", "monitor", "about"]) $(`view-${v}`).hidden = v !== tab;
if (location.hash.slice(1) !== tab) history.replaceState(null, "", tab === "chat" ? location.pathname : `#${tab}`);
if (tab === "chat") $("input").focus();
if (lastMetrics) render(lastMetrics);
}
for (const b of document.querySelectorAll(".st-tab")) b.onclick = () => showTab(b.dataset.tab);
window.addEventListener("hashchange", () => showTab(location.hash.slice(1)));
// ------------------------------------------------------------------ server access
function headers(json = false) {
const h = {};
const key = store.get("apikey", "");
if (key) h.Authorization = "Bearer " + key;
if (json) h["Content-Type"] = "application/json";
return h;
}
$("api-key").value = store.get("apikey", "");
$("api-key").onchange = () => { store.set("apikey", $("api-key").value.trim()); toast("success", "API key saved", "Kept in this browser only."); };
let health = {model: "strata", images: false, max_context: 0};
async function loadHealth() {
try {
health = await (await fetch("/health")).json();
$("attach-btn").title = health.images ? "Attach a text file or a picture (or drop it here)"
: "Attach a text file (or drop it here)";
$("chat-empty-sub").textContent = `${health.model} runs on this PC. Nothing leaves it.`;
} catch (e) {
setTimeout(loadHealth, 2000);
}
}
// ------------------------------------------------------------------ Monitor
const METRICS = [
{key: "speed", label: "Speed", icon: "gauge", unit: "tok/s", series: "tok_s"},
{key: "gpu", label: "GPU load", icon: "gpu", unit: "%", series: "gpu_util", max: 100},
{key: "vram", label: "VRAM", icon: "layers", unit: "GB", series: "gpu_mem_used"},
{key: "temp", label: "GPU temp", icon: "thermometer", unit: "°C", series: "gpu_temp", tone: "warn"},
{key: "power", label: "Power", icon: "bolt", unit: "W", series: "gpu_power"},
{key: "pcie", label: "PCIe", icon: "link", unit: "", series: "gpu_pcie_rx_mb", tone: "info"},
{key: "cpu", label: "CPU", icon: "cpu", unit: "%", series: "cpu", max: 100},
{key: "disk", label: "Disk read", icon: "disk", unit: "MB/s", series: "disk_read_mb", tone: "info"},
];
$("metrics").innerHTML = METRICS.map((m) => `
<div class="st-card metric-card"><div class="st-metric">
<span class="st-metric__label">${icon(m.icon, "st-icon st-icon--sm")}${esc(m.label)}</span>
<span class="st-metric__value" id="mv-${m.key}">–</span>
<span class="st-metric__sub" id="ms-${m.key}"></span>
<svg class="st-metric__spark" id="sp-${m.key}" viewBox="0 0 100 32" preserveAspectRatio="none"${m.tone ? ` data-tone="${m.tone}"` : ""}>
<path class="area" fill="currentColor" opacity=".12"/><path class="line" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/></svg>
</div></div>`).join("");
function spark(id, values, max) {
const svg = $(id);
const v = (values || []).map((x) => (x == null ? 0 : x));
if (v.length < 2) { svg.querySelector(".line").setAttribute("d", ""); svg.querySelector(".area").setAttribute("d", ""); return; }
const top = Math.max(max || 0, ...v, 1e-9);
const pts = v.map((x, i) => [(i / (v.length - 1)) * 100, 30 - (x / top) * 26]);
const line = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join("");
svg.querySelector(".line").setAttribute("d", line);
svg.querySelector(".area").setAttribute("d", `${line}L100,32L0,32Z`);
}
function setMetric(key, value, unit, sub) {
$(`mv-${key}`).innerHTML = value == null ? "–" : `${esc(value)}${unit ? `<small>${esc(unit)}</small>` : ""}`;
$(`ms-${key}`).textContent = sub || "";
}
let lastMetrics = null, metricsFailures = 0, keyWarned = false;
let reqShowAll = false; // the Monitor's request table: the last 12, or every one the server keeps (issue #35)
async function poll() {
try {
const r = await fetch(reqShowAll ? "/metrics?requests=all" : "/metrics", {headers: headers()});
if (r.status === 401) {
setPill("error", "API key needed");
if (!keyWarned) { keyWarned = true; toast("warn", "API key needed", "This server needs a key: add it under About > Settings.", 6000); }
} else if (r.ok) {
lastMetrics = await r.json();
metricsFailures = 0;
render(lastMetrics);
} else {
throw new Error(`HTTP ${r.status}`);
}
} catch (e) {
if (++metricsFailures === 3) setPill("error", "Server not reachable");
}
setTimeout(poll, 1000);
}
function setPill(state, text) {
$("pill").dataset.state = state === "error" ? "queued" : state;
$("pill-text").textContent = text;
}
function render(m) {
const live = m.live || {}, hw = m.hardware || {}, st = m.hardware_static || {}, eng = m.engine || {}, h = m.history || {};
const last = (m.requests || [])[0];
// the header pill
if (live.state === "reading") {
const pct = live.prompt_total ? Math.round((100 * live.prompt_read) / live.prompt_total) : null;
setPill("reading", pct != null ? `Reading prompt · ${pct}%` : "Reading prompt");
} else if (live.state === "generating") {
setPill("generating", `Generating · ${fmt(live.tok_s, 1)} tok/s`);
} else {
setPill("idle", "Idle");
}
if (live.queued > 0) setPill("queued", `${live.queued} queued`);
if (tab === "monitor") renderMonitor(live, hw, st, eng, h, last, m.requests || [], m.totals, m.requests_kept);
if (tab === "about") renderAbout(eng, hw, st);
}
function renderTotals(t) {
if (!t || !t.requests) return "";
const since = new Date(t.since * 1000).toLocaleString([], {weekday: "short", hour: "2-digit", minute: "2-digit"});
const read = t.prompt_tokens - t.reused;
const pSpeed = t.prompt_ms > 0 && read > 0 ? ` at ${fmt(read / (t.prompt_ms / 1000))} tok/s` : "";
const oSpeed = t.decode_ms > 0 && t.output_tokens > 0 ? ` at ${fmt(t.output_tokens / (t.decode_ms / 1000), 1)} tok/s` : "";
return `Since ${since}: ${fmt(t.requests)} requests · ${fmt(read)} prompt tokens read${pSpeed} (${fmt(t.reused)} reused) · ` +
`${fmt(t.output_tokens)} written${oSpeed}`;
}
function renderMonitor(live, hw, st, eng, h, last, requests, totals, kept) {
// model state
const on = live.queued > 0 ? "queued" : live.state;
for (const b of document.querySelectorAll("#state-badges .st-badge")) b.classList.toggle("on", b.dataset.s === on || b.dataset.s === live.state);
const prog = $("state-progress");
let label = "Waiting for a request", detail = "", pct = 0;
if (live.state === "reading") {
label = "Reading prompt";
prog.dataset.tone = "info";
if (live.prompt_total) {
pct = (100 * live.prompt_read) / live.prompt_total;
detail = `${fmt(live.prompt_read)} / ${fmt(live.prompt_total)} tokens · ${fmt(pct)}%`;
} else {
detail = `${fmt(live.prompt_tokens)} tokens`;
}
} else if (live.state === "generating") {
label = live.phase ? live.phase[0].toUpperCase() + live.phase.slice(1) : "Generating";
delete prog.dataset.tone;
pct = live.max_tokens ? Math.min(100, (100 * live.generated) / live.max_tokens) : 0;
detail = `${fmt(live.generated)} tokens · ${fmt(live.tok_s, 1)} tok/s`;
} else if (last) {
delete prog.dataset.tone;
detail = `last: ${fmt(last.output_tokens)} tokens${last.decode_tok_s ? ` at ${fmt(last.decode_tok_s, 1)} tok/s` : ""}`;
}
$("state-label").textContent = label;
$("state-detail").textContent = detail;
$("state-bar").style.width = `${pct}%`;
// the eight cards
const speed = live.state === "generating" ? live.tok_s : last ? last.decode_tok_s : null;
setMetric("speed", speed == null ? null : fmt(speed, 1), "tok/s", live.state === "generating" ? "now" : last ? "last request" : "");
spark("sp-speed", h.tok_s);
setMetric("gpu", hw.gpu_util == null ? null : fmt(hw.gpu_util), "%", st.gpu_name || "");
spark("sp-gpu", h.gpu_util, 100);
setMetric("vram", hw.gpu_mem_used == null ? null : gb(hw.gpu_mem_used), hw.gpu_mem_total ? `/ ${gb(hw.gpu_mem_total, 0)} GB` : "GB",
eng.expert_slots ? `${fmt(eng.expert_slots)} experts cached` : "");
spark("sp-vram", h.gpu_mem_used, hw.gpu_mem_total);
setMetric("temp", hw.gpu_temp == null ? null : fmt(hw.gpu_temp), "°C", "");
spark("sp-temp", h.gpu_temp, 90);
setMetric("power", hw.gpu_power == null ? null : fmt(hw.gpu_power), "W", hw.gpu_power_limit ? `of ${fmt(hw.gpu_power_limit)} W limit` : "");
spark("sp-power", h.gpu_power, hw.gpu_power_limit);
const gen = hw.gpu_pcie_gen_max || hw.gpu_pcie_gen;
setMetric("pcie", gen ? `Gen${gen}` : null, hw.gpu_pcie_width ? `x${hw.gpu_pcie_width}` : "",
hw.gpu_pcie_rx_mb == null ? "" : `to GPU ${fmt(hw.gpu_pcie_rx_mb, hw.gpu_pcie_rx_mb < 10 ? 1 : 0)} MB/s` +
(hw.gpu_pcie_gen && gen && hw.gpu_pcie_gen < gen ? ` · idle Gen${hw.gpu_pcie_gen}` : ""));
spark("sp-pcie", h.gpu_pcie_rx_mb);
setMetric("cpu", hw.cpu == null ? null : fmt(hw.cpu), "%", st.threads ? `${st.cores ? `${st.cores} cores · ` : ""}${st.threads} threads` : "");
spark("sp-cpu", h.cpu, 100);
if (hw.disk_read_mb == null) {
setMetric("disk", null, "", st.psutil ? "" : "needs psutil (setup installs it)");
} else {
const big = hw.disk_read_mb >= 1000;
setMetric("disk", big ? fmt(hw.disk_read_mb / 1024, 2) : fmt(hw.disk_read_mb, hw.disk_read_mb < 10 ? 1 : 0), big ? "GB/s" : "MB/s",
hw.disk_write_mb == null ? "" : `write ${fmt(hw.disk_write_mb, 1)} MB/s`);
}
spark("sp-disk", h.disk_read_mb);
// context fill: the running request, else the last one
const ctx = eng.max_context || 0;
let used = 0;
if (live.state !== "idle") used = (live.prompt_tokens || 0) + (live.generated || 0);
else if (last) used = (last.prompt_tokens || 0) + (last.output_tokens || 0);
const frac = ctx ? Math.min(1, used / ctx) : 0;
$("ctx-fill").setAttribute("stroke-dasharray", `${(235.6 * frac).toFixed(1)} 314.2`);
$("ctx-fill").style.opacity = 235.6 * frac >= 3 ? "1" : "0"; // a near-zero arc would draw just its round cap
$("ctx-pct").textContent = `${Math.round(frac * 100)}%`;
$("ctx-sub").textContent = ctx ? `${kfmt(used)} / ${ctxfmt(ctx)}` : "–";
const cacheBytes = (eng.expert_cache_mib || 0) * 1048576;
$("slots-text").textContent = eng.expert_slots ? `${fmt(eng.expert_slots)} · ${gb(cacheBytes)} GB` : "–";
$("slots-bar").style.width = hw.gpu_mem_total ? `${Math.min(100, (100 * cacheBytes) / hw.gpu_mem_total)}%` : "0%";
$("ram-text").textContent = hw.ram_total ? `${gb(hw.ram_used)} / ${gb(hw.ram_total, 0)} GB` : "–";
const ramPct = hw.ram_total ? (100 * hw.ram_used) / hw.ram_total : 0;
$("ram-bar").style.width = `${ramPct}%`;
if (ramPct > 92) $("ram-progress").dataset.tone = "danger"; else delete $("ram-progress").dataset.tone;
$("temp-text").textContent = hw.gpu_temp == null ? "–" : `${fmt(hw.gpu_temp)} °C`;
$("temp-bar").style.width = hw.gpu_temp == null ? "0%" : `${Math.min(100, hw.gpu_temp)}%`;
// recent requests
const body = $("req-body");
if (!requests.length) {
body.innerHTML = `<tr><td colspan="7" class="muted">No requests yet</td></tr>`;
} else {
const badge = {stop: ["", "Done"], length: ["", "Max tokens"], cancel: ["st-badge--queued", "Stopped"],
disconnect: ["st-badge--queued", "Closed"], error: ["st-badge--error", "Error"]};
body.innerHTML = requests.slice(0, reqShowAll ? requests.length : 12).map((r) => {
const [cls, text] = badge[r.finish] || ["", r.finish || "–"];
const t = new Date(r.time * 1000).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"});
const proj = r.projection == null ? "" : ` <span class="st-badge${r.projection ? " st-badge--reading" : ""}" title="experimental speed projection ${r.projection ? "on" : "off"}">${r.projection ? "ESP" : "stock"}</span>`;
return `<tr><td>${esc(t)}</td><td><span class="st-badge ${cls}">${esc(text)}</span>${proj}</td><td class="num">${fmt(r.prompt_tokens)}</td>
<td class="num">${fmt(r.reused)}</td><td class="num">${fmt(r.output_tokens)}</td><td class="num">${fmt(r.decode_tok_s, 1)}</td>
<td class="num">${fmt(r.duration_s, 1)} s</td></tr>`;
}).join("");
}
const all = $("req-all");
kept = kept == null ? requests.length : kept;
all.hidden = kept <= 12;
all.textContent = reqShowAll ? "Show fewer" : `Show all (${kept})`;
$("req-wrap").classList.toggle("all", reqShowAll);
$("req-totals").textContent = renderTotals(totals);
}
function facts(el, rows) {
el.innerHTML = rows.filter((r) => r[1] != null && r[1] !== "").map(([k, v, copy]) =>
`<dt>${esc(k)}</dt><dd>${copy ? `<code>${esc(v)}</code><button class="st-btn st-btn--icon" data-copy="${esc(v)}" aria-label="Copy">${icon("copy")}</button>` : esc(v)}</dd>`).join("");
}
// INFO cvec=project:4-44[:singleL] | add:A-B | 0
function projectionText(c) {
if (!c || c === "0" || c === 0) return null;
const [mode, range, single] = String(c).split(":");
const [a, b] = (range || "").split("-");
return `${mode === "project" ? "Projection" : "Additive"} control vector on layers ${a}–${b}` +
`${single ? ` (layer ${single.replace("single", "")}'s direction)` : ""}. Per chat in Sampling. Its package ` +
"describes the vector as a refusal-direction projection; measure the speed yourself";
}
function renderAbout(eng, hw, st) {
const kv = {int8: "8-bit", q4_0: "4-bit (Hadamard-rotated)", fp16: "16-bit"}[eng.kv] || eng.kv;
facts($("facts-engine"), [
["Model", eng.model],
["Engine", eng.version ? `v${eng.version}` : "built from source"],
["Context", eng.max_context ? `${fmt(eng.max_context)} tokens` : null],
["KV cache", kv ? `${kv}${eng.kv_resident ? `, streamed: ${fmt(eng.kv_resident)} positions per layer in VRAM, the rest in RAM` : ", all in VRAM"}` : null],
["Experts in VRAM", eng.expert_slots ? `${fmt(eng.expert_slots)} (${gb((eng.expert_cache_mib || 0) * 1048576)} GB)` : null],
["Speculation", eng.spec ? `MTP drafts up to ${Math.max(0, (eng.mtp_max || eng.spec) - 1)} tokens${eng.lookup ? ", prompt lookup on" : ""}` : null],
["Images", eng.images ? "on" : "off"],
["Experimental speed projection", projectionText(eng.cvec)],
]);
facts($("facts-hw"), [
["GPU", st.gpu_name ? `${st.gpu_name}${hw.gpu_mem_total ? `, ${gb(hw.gpu_mem_total, 0)} GB` : ""}` : "not readable (NVML)"],
["CPU", st.cpu_name ? `${st.cpu_name}${st.threads ? `, ${st.threads} threads` : ""}` : null],
["RAM", hw.ram_total ? `${gb(hw.ram_total, 0)} GB` : null],
]);
const base = location.origin;
facts($("facts-api"), [
["OpenAI base URL", `${base}/v1`, true],
["Anthropic base URL", base, true],
["Model name", eng.model, true],
]);
}
document.addEventListener("click", (e) => {
const b = e.target.closest("[data-copy]");
if (b) copyText(b.dataset.copy, b);
});
$("req-all").addEventListener("click", () => { reqShowAll = !reqShowAll; if (lastMetrics) render(lastMetrics); });
// ------------------------------------------------------------------ Markdown (escaped first, then formatted)
function inline(s) {
const codes = [];
s = s.replace(/`([^`\n]+)`/g, (_, c) => { codes.push(c); return `\u0000${codes.length - 1}\u0000`; });
s = esc(s)
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*\w])\*([^*\n]+)\*(?![*\w])/g, "$1<em>$2</em>")
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
return s.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code class="inline">${esc(codes[+i])}</code>`);
}
function codeBlock(lang, code) {
return `<div class="st-code"><div class="st-code__head"><span>${esc(lang || "code")}</span>` +
`<button class="st-btn st-btn--icon" data-code-copy aria-label="Copy code">${icon("copy")}</button></div>` +
`<pre><code>${esc(code)}</code></pre></div>`;
}
function blocks(text) {
const out = [], lines = text.split("\n");
let para = [], list = null;
const flushPara = () => { if (para.length) out.push(`<p>${para.map(inline).join("<br>")}</p>`); para = []; };
const flushList = () => { if (list) out.push(`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join("")}</${list.tag}>`); list = null; };
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
let m;
if (!l.trim()) { flushPara(); flushList(); continue; }
if ((m = l.match(/^(#{1,6})\s+(.*)$/))) { flushPara(); flushList(); out.push(`<${m[1].length <= 2 ? "h3" : "h4"}>${inline(m[2])}</${m[1].length <= 2 ? "h3" : "h4"}>`); continue; }
if (/^\s*([-*_])\s*\1\s*\1[\s\1]*$/.test(l)) { flushPara(); flushList(); out.push("<hr>"); continue; }
if ((m = l.match(/^>\s?(.*)$/))) { flushPara(); flushList(); out.push(`<blockquote>${inline(m[1])}</blockquote>`); continue; }
if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|?[\s:-]+\|[\s|:-]*$/.test(lines[i + 1])) {
flushPara(); flushList();
const cells = (row) => row.trim().replace(/^\||\|$/g, "").split("|").map((c) => inline(c.trim()));
let html = `<table><thead><tr>${cells(l).map((c) => `<th>${c}</th>`).join("")}</tr></thead><tbody>`;
i += 2;
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) html += `<tr>${cells(lines[i++]).map((c) => `<td>${c}</td>`).join("")}</tr>`;
i--;
out.push(html + "</tbody></table>");
continue;
}
if ((m = l.match(/^\s*(?:[-*+]|(\d+)[.)])\s+(.*)$/))) {
flushPara();
const tag = m[1] ? "ol" : "ul";
if (!list || list.tag !== tag) { flushList(); list = {tag, items: []}; }
list.items.push(m[2]);
continue;
}
if (list && /^\s{2,}\S/.test(l)) { list.items[list.items.length - 1] += " " + l.trim(); continue; }
flushList();
para.push(l);
}
flushPara(); flushList();
return out.join("");
}
function markdown(text) {
let html = "", rest = text;
for (;;) {
const m = rest.match(/(^|\n)```([^\n`]*)\n/);
if (!m) { html += blocks(rest); break; }
html += blocks(rest.slice(0, m.index));
rest = rest.slice(m.index + m[0].length);
const end = rest.match(/(^|\n)```[ \t]*(\n|$)/);
if (!end) { html += codeBlock(m[2].trim(), rest); break; } // still streaming
html += codeBlock(m[2].trim(), rest.slice(0, end.index));
rest = rest.slice(end.index + end[0].length);
}
return html;
}
// ------------------------------------------------------------------ Chat
const DEFAULTS = {thinking: "high", temperature: 0.6, top_p: 0.95, top_k: 20, max: "", seed: "", show: true, esp: true};
let settings = {...DEFAULTS, ...store.get("sampling", {})};
let messages = store.get("chat", []);
let attachments = []; // {name, url}
let busy = null; // {controller, msg}
function saveChat() {
store.set("chat", messages.map((m) => ({...m, images: (m.images || []).map((i) => ({name: i.name})),
files: (m.files || []).map((f) => ({name: f.name}))})));
}
function timeStr(t) { return new Date(t).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"}); }
function msgEl(m, i) {
const el = document.createElement("div");
el.className = `st-msg st-msg--${m.role}`;
el.dataset.i = i;
if (m.role === "user") {
if (m.files && m.files.length) {
const wrap = document.createElement("div");
wrap.className = "msg-images";
for (const f of m.files) {
const c = document.createElement("span");
c.className = "chip";
c.innerHTML = icon("attach", "st-icon st-icon--sm");
c.append(f.name);
wrap.appendChild(c);
}
el.appendChild(wrap);
}
if (m.images && m.images.length) {
const wrap = document.createElement("div");
wrap.className = "msg-images";
for (const im of m.images) {
if (im.url) { const img = document.createElement("img"); img.src = im.url; img.alt = im.name || "image"; wrap.appendChild(img); }
else { const c = document.createElement("span"); c.className = "chip"; c.innerHTML = icon("image", "st-icon st-icon--sm"); c.append(im.name || "image"); wrap.appendChild(c); }
}
el.appendChild(wrap);
}
const b = document.createElement("div");
b.className = "st-bubble";
b.textContent = m.text;
el.appendChild(b);
const meta = document.createElement("div");
meta.className = "st-msg__meta";
meta.textContent = `You · ${timeStr(m.time)}`;
el.appendChild(meta);
} else {
el.innerHTML = `<details class="st-collapse" hidden><summary>${icon("thinking", "st-icon st-icon--sm")}<span class="think-title"></span>` +
`${icon("chevron", "st-icon st-icon--sm st-chev")}</summary><div class="st-collapse__body thinking"></div></details>` +
`<div class="st-bubble"></div><div class="st-msg__meta"><span class="meta-text"></span>` +
`<button class="st-btn st-btn--icon" data-msg-copy aria-label="Copy the answer" title="Copy">${icon("copy")}</button></div>`;
updateAssistant(el, m, false);
}
return el;
}
function updateAssistant(el, m, streaming) {
const det = el.querySelector("details");
if (m.reasoning) {
det.hidden = false;
const thinkingNow = streaming && !m.text;
el.querySelector(".think-title").textContent = thinkingNow ? "Thinking…" :
m.thinkSecs != null ? `Thought for ${fmt(m.thinkSecs, 1)} s` : "Thoughts";
const body = el.querySelector(".thinking");
if (det.open || thinkingNow) body.textContent = m.reasoning;
else body.dataset.pending = "1";
// open while it streams (if wanted), closed once the answer starts - unless the user toggled it themselves
if (thinkingNow && settings.show && !det.dataset.touched && !det.open) { det._auto = true; det.open = true; }
if (!thinkingNow && det.open && !det.dataset.touched) { det._auto = true; det.open = false; }
}
const bubble = el.querySelector(".st-bubble");
if (m.error) {
bubble.innerHTML = `<div class="msg-error"></div>`;
bubble.firstChild.textContent = m.error;
} else if (!m.text && streaming) {
bubble.innerHTML = m.reasoning ? `<span class="muted cursor">Writing</span>` : `<span class="cursor"></span>`;
} else {
bubble.innerHTML = markdown(m.text || "");
if (streaming) bubble.classList.add("cursor"); else bubble.classList.remove("cursor");
}
el.querySelector(".meta-text").textContent = m.meta || (streaming ? "" : m.stopped ? "Stopped" : "");
el.querySelector("[data-msg-copy]").hidden = streaming || !m.text;
}
function renderChat() {
const chat = $("chat");
chat.querySelectorAll(".st-msg").forEach((e) => e.remove());
$("chat-empty").hidden = messages.length > 0;
messages.forEach((m, i) => chat.appendChild(msgEl(m, i)));
scrollDown(true);
}
function nearBottom() { const s = $("chat-scroll"); return s.scrollHeight - s.scrollTop - s.clientHeight < 120; }
function scrollDown(force) { const s = $("chat-scroll"); if (force || nearBottom()) s.scrollTop = s.scrollHeight; }
$("chat").addEventListener("click", (e) => {
const cc = e.target.closest("[data-code-copy]");
if (cc) { copyText(cc.closest(".st-code").querySelector("pre").textContent, cc); return; }
const mc = e.target.closest("[data-msg-copy]");
if (mc) { const i = +mc.closest(".st-msg").dataset.i; copyText(messages[i].text, mc); }
});
$("chat").addEventListener("toggle", (e) => {
const d = e.target;
if (d.tagName !== "DETAILS") return;
if (d._auto) { d._auto = false; return; } // our own open/close, not the user's
d.dataset.touched = "1";
const body = d.querySelector(".thinking");
if (d.open && body.dataset.pending) { body.textContent = messages[+d.closest(".st-msg").dataset.i].reasoning; delete body.dataset.pending; }
}, true);
function apiMessages() {
const out = [];
for (const m of messages) {
if (m.role === "user") {
const imgs = (m.images || []).filter((i) => i.url);
const text = userText(m);
out.push({role: "user", content: imgs.length ? [{type: "text", text},
...imgs.map((i) => ({type: "image_url", image_url: {url: i.url}}))] : text});
} else if (m.text && !m.error) {
out.push({role: "assistant", content: m.text});
}
}
return out;
}
function setBusy(on) {
$("stop-btn").hidden = !on;
$("send-btn").disabled = on;
$("composer-hint").textContent = on ? "" : "Shift+Enter: new line";
}
async function send() {
const text = $("input").value.trim();
if ((!text && !attachments.length) || busy) return;
messages.push({role: "user", text, images: attachments.filter((a) => a.kind !== "file"),
files: attachments.filter((a) => a.kind === "file"), time: Date.now()});
attachments = [];
renderAttachments();
$("input").value = "";
autosize();
const m = {role: "assistant", text: "", reasoning: "", time: Date.now()};
messages.push(m);
renderChat();
const el = $("chat").lastElementChild;
const controller = new AbortController();
busy = {controller, msg: m};
setBusy(true);
const body = {model: health.model, messages: apiMessages(), stream: true,
reasoning_effort: settings.thinking};
if (settings.temperature > 0) {
Object.assign(body, {temperature: +settings.temperature, top_p: +settings.top_p, top_k: +settings.top_k});
} else {
body.temperature = 0;
}
if (settings.seed) body.seed = +settings.seed;
if (settings.max) body.max_tokens = +settings.max;
if (projectionLoaded()) body.experimental_speed_projection = !!settings.esp;
let firstAt = null, thinkStart = null, usage = null, frame = 0;
const paint = () => { frame = 0; updateAssistant(el, m, true); scrollDown(); };
try {
const r = await fetch("/v1/chat/completions", {method: "POST", headers: headers(true), body: JSON.stringify(body),
signal: controller.signal});
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { msg = (await r.json()).error.message || msg; } catch (e) { /* not json */ }
if (r.status === 401) msg = "This server needs an API key: add it under About > Settings.";
throw new Error(msg);
}
const reader = r.body.getReader(), dec = new TextDecoder();
let buf = "";
for (;;) {
const {value, done} = await reader.read();
if (done) break;
buf += dec.decode(value, {stream: true});
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line.startsWith("data:")) continue; // ": keep-alive" comments while a long prompt is read
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
let j;
try { j = JSON.parse(data); } catch (e) { continue; }
if (j.error) throw new Error(j.error.message || "the engine reported an error");
if (j.usage) usage = j.usage;
const d = (j.choices && j.choices[0] && j.choices[0].delta) || {};
if (d.reasoning_content) { if (!firstAt) firstAt = performance.now(); if (!thinkStart) thinkStart = performance.now(); m.reasoning += d.reasoning_content; }
if (d.content) {
if (!firstAt) firstAt = performance.now();
if (thinkStart && m.thinkSecs == null) m.thinkSecs = (performance.now() - thinkStart) / 1000;
m.text += d.content;
}
if (!frame) frame = requestAnimationFrame(paint);
}
}
} catch (e) {
if (e.name === "AbortError") m.stopped = true;
else { m.error = e.message || String(e); toast("error", "The request failed", m.error, 6000); }
}
if (thinkStart && m.thinkSecs == null) m.thinkSecs = (performance.now() - thinkStart) / 1000;
const n = usage ? usage.completion_tokens : null;
if (n && firstAt) {
const secs = (performance.now() - firstAt) / 1000;
m.meta = `${fmt(n)} tokens${secs > 0.25 ? ` · ${fmt(n / secs, 1)} tok/s` : ""}${m.stopped ? " · stopped" : ""}` +
(projectionLoaded() ? (settings.esp ? " · projection on" : " · projection off") : "");
} else if (m.stopped) {
m.meta = "Stopped";
}
busy = null;
setBusy(false);
if (frame) cancelAnimationFrame(frame);
updateAssistant(el, m, false);
saveChat();
scrollDown();
}
$("composer").onsubmit = (e) => { e.preventDefault(); send(); };
$("stop-btn").onclick = () => { if (busy) busy.controller.abort(); };
$("input").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
});
function autosize() { const t = $("input"); t.style.height = "auto"; t.style.height = `${Math.min(t.scrollHeight, innerHeight * 0.4)}px`; }
$("input").addEventListener("input", autosize);
$("new-btn").onclick = () => {
if (busy) { toast("warn", "Still writing", "Stop the answer first."); return; }
if (!messages.length) return;
const backup = messages;
messages = [];
saveChat();
renderChat();
toast("info", "New chat", "The last one was cleared.", 6000, {label: "Undo", run: () => { messages = backup; saveChat(); renderChat(); }});
};
$("export-btn").onclick = () => {
if (!messages.length) { toast("info", "Nothing to save yet"); return; }
const md = messages.map((m) => m.role === "user" ? `## You\n\n${m.text}\n` :
`## ${health.model}\n\n${m.reasoning ? `<details><summary>Thinking</summary>\n\n${m.reasoning}\n\n</details>\n\n` : ""}${m.text || m.error || ""}\n`).join("\n");
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([md], {type: "text/markdown"}));
a.download = `strata-chat-${new Date().toISOString().slice(0, 16).replace(/[:T]/g, "-")}.md`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
};
// pictures and text files: the attach button, dropping them on the chat, or pasting a picture (issue #30)
const TEXT_EXT = /\.(txt|md|markdown|rst|tex|py|pyi|ipynb|js|mjs|cjs|ts|tsx|jsx|vue|svelte|json|jsonl|csv|tsv|log|ya?ml|toml|ini|cfg|conf|env|xml|html?|css|scss|less|c|cc|cpp|cxx|h|hh|hpp|cu|cuh|rs|go|java|kt|kts|swift|rb|php|pl|lua|r|jl|scala|sql|sh|bash|zsh|fish|ps1|psm1|bat|cmd|diff|patch|gradle|cmake|mk|dockerfile|gitignore|proto|graphql)$/i;
const MAX_TEXT_FILE = 512 * 1024;
function isTextFile(f) {
return f.type.startsWith("text/") || /json|xml|javascript|yaml|toml|x-sh|x-python/.test(f.type) ||
TEXT_EXT.test(f.name) || /(^|[\\/])(makefile|dockerfile|readme|license)$/i.test(f.name);
}
function addFiles(files) {
for (const f of files) {
if (f.type.startsWith("image/")) {
if (!health.images) { toast("warn", "Pictures are off", "This model was set up for text only."); continue; }
if (f.size > 20e6) { toast("warn", "Picture too large", `${f.name} is over 20 MB.`); continue; }
const r = new FileReader();
r.onload = () => { attachments.push({kind: "image", name: f.name || "pasted image", url: r.result}); renderAttachments(); };
r.readAsDataURL(f);
continue;
}
if (!isTextFile(f)) { toast("warn", "Not a text file", `${f.name}: attach text files (code, notes, logs, data)${health.images ? " or pictures" : ""}.`); continue; }
if (f.size > MAX_TEXT_FILE) { toast("warn", "File too large", `${f.name} is over 512 KB.`); continue; }
const r = new FileReader();
r.onload = () => {
const text = String(r.result);
if (text.includes("\u0000")) { toast("warn", "Not a text file", `${f.name} looks like a binary file.`); return; }
attachments.push({kind: "file", name: f.name, text});
renderAttachments();
};
r.readAsText(f);
}
}
// a file's text in the message, fenced with more backticks than it contains itself
function fileBlock(f) {
const longest = Math.max(2, ...(f.text.match(/`+/g) || []).map((s) => s.length));
const fence = "`".repeat(longest + 1);
return `File: ${f.name}\n${fence}\n${f.text}\n${fence}`;
}
function userText(m) {
const files = (m.files || []).filter((f) => f.text != null);
return [m.text, ...files.map(fileBlock)].filter((s) => s).join("\n\n");
}
function renderAttachments() {
const box = $("attachments");
box.hidden = !attachments.length;
box.innerHTML = "";
attachments.forEach((a, i) => {
const c = document.createElement("span");
c.className = "chip";
c.innerHTML = icon(a.kind === "file" ? "attach" : "image", "st-icon st-icon--sm");
c.append(a.name);
const x = document.createElement("button");
x.type = "button"; x.className = "st-btn st-btn--icon"; x.setAttribute("aria-label", "Remove");
x.innerHTML = icon("trash");
x.onclick = () => { attachments.splice(i, 1); renderAttachments(); };
c.appendChild(x);
box.appendChild(c);
});
}
$("attach-btn").onclick = () => $("file").click();
// drop files on the chat or the message box
for (const id of ["chat", "composer"]) {
const el = $(id);
el.addEventListener("dragover", (e) => {
if (![...(e.dataTransfer || {}).types || []].includes("Files")) return;
e.preventDefault();
$("composer").classList.add("dragging");
});
el.addEventListener("dragleave", () => $("composer").classList.remove("dragging"));
el.addEventListener("drop", (e) => {
$("composer").classList.remove("dragging");
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
e.preventDefault();
addFiles(e.dataTransfer.files);
$("input").focus();
});
}
$("file").onchange = () => { addFiles($("file").files); $("file").value = ""; };
$("input").addEventListener("paste", (e) => {
if (!health.images) return;
const files = [...(e.clipboardData || {}).files || []].filter((f) => f.type.startsWith("image/"));
if (files.length) { e.preventDefault(); addFiles(files); }
});
// ------------------------------------------------------------------ the sampling drawer
function openDrawer(open) {
$("drawer").dataset.open = String(open);
$("drawer").setAttribute("aria-hidden", String(!open));
$("scrim").hidden = !open;
if (open) { loadDrawer(); loadShared(); }
}
function loadDrawer(s = settings) {
for (const b of $("s-thinking").children) b.setAttribute("aria-checked", String(b.dataset.v === s.thinking));
$("s-temp").value = s.temperature; $("s-topp").value = s.top_p; $("s-topk").value = s.top_k;
$("s-max").value = s.max; $("s-seed").value = s.seed;
$("s-show").setAttribute("aria-checked", String(!!s.show));
$("s-esp").setAttribute("aria-checked", String(s.esp !== false));
$("esp-row").hidden = !projectionLoaded();
$("s-share").setAttribute("aria-checked", String(sharedOn));
outputs();
}
// "Use for other apps too": the server keeps these settings as every client's defaults (GET/POST /settings)
let sharedOn = false;
async function loadShared() {
try {
const r = await fetch("/settings", {headers: headers()});
if (r.ok) sharedOn = !!(await r.json()).shared;
} catch (e) { /* an older server: the switch just stays off */ }
$("s-share").setAttribute("aria-checked", String(sharedOn));
}
function sharedDefaults(s) {
const d = {reasoning_effort: s.thinking, temperature: +s.temperature};
if (+s.temperature > 0) Object.assign(d, {top_p: +s.top_p, top_k: +s.top_k});
if (s.seed) d.seed = +s.seed;
if (s.max) d.max_tokens = +s.max;
if (projectionLoaded()) d.experimental_speed_projection = s.esp !== false;
return d;
}
async function saveShared(on, s) {
const r = await fetch("/settings", {method: "POST", headers: headers(true),
body: JSON.stringify({defaults: on ? sharedDefaults(s) : null})});
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try { msg = (await r.json()).error.message || msg; } catch (e) { /* not json */ }
throw new Error(msg);
}
sharedOn = !!(await r.json()).shared;
}
// the engine was started with the experimental-speed-projection control vector (INFO cvec=...)
function projectionLoaded() {
const c = lastMetrics && lastMetrics.engine ? lastMetrics.engine.cvec : 0;
return !!c && c !== "0";
}
function outputs() {
const t = +$("s-temp").value;
$("o-temp").textContent = t === 0 ? "0 · greedy" : t.toFixed(2);
$("o-topp").textContent = (+$("s-topp").value).toFixed(2);
$("o-topk").textContent = $("s-topk").value;
const sel = [...$("s-thinking").children].find((b) => b.getAttribute("aria-checked") === "true");
$("o-thinking").textContent = sel ? {none: "answers right away", low: "short", medium: "medium", high: "thorough (default)"}[sel.dataset.v] : "";
for (const id of ["s-topp", "s-topk"]) $(id).disabled = t === 0;
}
for (const b of $("s-thinking").children) b.onclick = () => { for (const x of $("s-thinking").children) x.setAttribute("aria-checked", String(x === b)); outputs(); };
for (const id of ["s-temp", "s-topp", "s-topk"]) $(id).oninput = outputs;
$("s-show").onclick = () => $("s-show").setAttribute("aria-checked", String($("s-show").getAttribute("aria-checked") !== "true"));
$("s-esp").onclick = () => $("s-esp").setAttribute("aria-checked", String($("s-esp").getAttribute("aria-checked") !== "true"));
$("s-share").onclick = () => $("s-share").setAttribute("aria-checked", String($("s-share").getAttribute("aria-checked") !== "true"));
$("s-reset").onclick = () => loadDrawer(DEFAULTS);
$("s-apply").onclick = async () => {
const sel = [...$("s-thinking").children].find((b) => b.getAttribute("aria-checked") === "true");
settings = {thinking: sel ? sel.dataset.v : "high", temperature: +$("s-temp").value, top_p: +$("s-topp").value,
top_k: +$("s-topk").value, max: $("s-max").value.trim(), seed: $("s-seed").value.trim(),
show: $("s-show").getAttribute("aria-checked") === "true",
esp: $("s-esp").getAttribute("aria-checked") === "true"};
store.set("sampling", settings);
const share = $("s-share").getAttribute("aria-checked") === "true";
openDrawer(false);
if (share || sharedOn) {
try {
await saveShared(share, settings);
toast("success", "Sampling saved", share ? "Other apps (omp, API clients) use these settings from their next request."
: "Other apps use their own settings again.");
} catch (e) {
toast("error", "Saved here, but not for other apps", e.message, 6000);
}
return;
}
toast("success", "Sampling saved", settings.temperature === 0 ? "Greedy: the same question gives the same answer." : "");
};
$("sampling-btn").onclick = () => openDrawer(true);
$("drawer-close").onclick = () => openDrawer(false);
$("scrim").onclick = () => openDrawer(false);
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("drawer").dataset.open === "true") openDrawer(false); });
// ------------------------------------------------------------------ start
setBusy(false);
renderChat();
const startQuestion = new URLSearchParams(location.search).get("q"); // /?q=... starts a chat (a shortcut)
if (startQuestion) history.replaceState(null, "", location.pathname + location.hash);
loadHealth().then(() => { if (startQuestion) { $("input").value = startQuestion; send(); } });
showTab(location.hash.slice(1) || "chat");
poll();
+128
View File
@@ -0,0 +1,128 @@
/* Strata UI — components. Requires tokens.css. */
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0;background:var(--st-bg);color:var(--st-ink);font-family:var(--st-font);font-size:var(--st-fs-base);line-height:var(--st-lh-body);-webkit-font-smoothing:antialiased}
a{color:var(--st-accent-text);text-decoration:none} a:hover{text-decoration:underline}
:focus-visible{outline:2px solid var(--st-focus);outline-offset:2px}
/* icon — <svg class="st-icon"><use href="icons/sprite.svg#i-chat"/></svg> */
.st-icon{width:20px;height:20px;flex:none;color:inherit}
.st-icon--sm{width:16px;height:16px} .st-icon--lg{width:24px;height:24px}
/* header */
.st-header{display:flex;align-items:center;gap:var(--st-s-4);height:60px;padding:0 var(--st-s-6);background:var(--st-surface);border-bottom:1px solid var(--st-line)}
.st-header__brand{font-weight:900;font-size:var(--st-fs-lg);letter-spacing:-.01em}
.st-header__spacer{flex:1}
/* status pill */
.st-pill{display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px;border-radius:var(--st-r-pill);border:1px solid var(--st-line);background:var(--st-surface-2);font-size:var(--st-fs-sm);font-weight:500;color:var(--st-ink-soft);font-variant-numeric:tabular-nums}
.st-pill__dot{width:8px;height:8px;border-radius:50%;background:var(--st-ink-muted)}
.st-pill[data-state="generating"] .st-pill__dot{background:var(--st-accent);box-shadow:0 0 0 4px var(--st-accent-tint)}
.st-pill[data-state="reading"] .st-pill__dot{background:var(--st-info);box-shadow:0 0 0 4px var(--st-info-tint)}
.st-pill[data-state="queued"] .st-pill__dot{background:var(--st-warn)}
/* tabs */
.st-tabs{display:flex;gap:4px;padding:4px;background:var(--st-surface-2);border:1px solid var(--st-line);border-radius:var(--st-r-md)}
.st-tab{display:inline-flex;align-items:center;gap:8px;height:36px;padding:0 14px;border:0;border-radius:10px;background:transparent;color:var(--st-ink-muted);font:500 var(--st-fs-md)/1 var(--st-font);cursor:pointer}
.st-tab:hover{color:var(--st-ink)}
.st-tab[aria-selected="true"]{background:var(--st-surface);color:var(--st-ink);font-weight:700;box-shadow:var(--st-sh-card)}
/* card + metric */
.st-card{background:var(--st-surface);border:1px solid var(--st-line);border-radius:var(--st-r-lg);box-shadow:var(--st-sh-card);padding:var(--st-s-5)}
.st-metric{display:flex;flex-direction:column;gap:10px}
.st-metric__label{display:flex;align-items:center;gap:8px;font-size:var(--st-fs-sm);font-weight:500;color:var(--st-ink-muted)}
.st-metric__value{font-weight:900;font-size:var(--st-fs-stat);line-height:1;letter-spacing:-.01em;font-variant-numeric:tabular-nums}
.st-metric__unit{font-weight:500;font-size:var(--st-fs-md);color:var(--st-ink-muted);margin-left:4px}
.st-metric__spark{width:100%;height:32px;color:var(--st-accent)}
/* progress + gauge */
.st-progress{height:8px;border-radius:var(--st-r-pill);background:var(--st-surface-2);border:1px solid var(--st-line-soft);overflow:hidden}
.st-progress__bar{height:100%;border-radius:inherit;background:var(--st-accent);transition:width var(--st-dur) var(--st-ease)}
.st-progress[data-tone="info"] .st-progress__bar{background:var(--st-info)}
.st-progress[data-tone="warn"] .st-progress__bar{background:var(--st-warn)}
.st-progress[data-tone="danger"] .st-progress__bar{background:var(--st-danger)}
/* gauge: see README for SVG markup (stroke-dasharray on a 270° arc) */
.st-gauge{position:relative;width:120px;height:120px}
.st-gauge__track{stroke:var(--st-surface-2)} .st-gauge__fill{stroke:var(--st-accent)}
/* table */
.st-table{width:100%;border-collapse:collapse;font-size:var(--st-fs-md);font-variant-numeric:tabular-nums}
.st-table th{text-align:left;font-weight:500;font-size:var(--st-fs-xs);letter-spacing:.04em;text-transform:uppercase;color:var(--st-ink-muted);padding:10px 12px;border-bottom:1px solid var(--st-line)}
.st-table td{padding:12px;border-bottom:1px solid var(--st-line-soft);color:var(--st-ink-soft)}
.st-table tr:hover td{background:var(--st-surface-2)}
.st-table .num{text-align:right}
/* badges */
.st-badge{display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 10px;border-radius:var(--st-r-pill);font-size:var(--st-fs-xs);font-weight:700;background:var(--st-surface-2);color:var(--st-ink-muted);border:1px solid var(--st-line)}
.st-badge::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}
.st-badge--reading{background:var(--st-info-tint);color:var(--st-info-text);border-color:transparent}
.st-badge--generating{background:var(--st-accent-tint);color:var(--st-accent-text);border-color:transparent}
.st-badge--queued{background:var(--st-warn-tint);color:var(--st-warn-text);border-color:transparent}
.st-badge--error{background:var(--st-danger-tint);color:var(--st-danger-text);border-color:transparent}
/* chat */
.st-msg{display:flex;flex-direction:column;gap:6px;max-width:720px}
.st-msg--user{align-self:flex-end;align-items:flex-end}
.st-bubble{padding:12px 16px;border-radius:var(--st-r-lg);font-size:var(--st-fs-base);line-height:1.55;color:var(--st-ink-soft)}
.st-msg--user .st-bubble{background:var(--st-accent);color:var(--st-accent-ink);border-bottom-right-radius:6px}
.st-msg--assistant .st-bubble{background:var(--st-surface);border:1px solid var(--st-line);border-bottom-left-radius:6px}
.st-msg__meta{font-size:var(--st-fs-xs);color:var(--st-ink-muted);display:flex;gap:8px;align-items:center}
/* collapsible — <details class="st-collapse"><summary>…</summary><div class="st-collapse__body">…</div></details> */
.st-collapse{border:1px solid var(--st-line);border-radius:var(--st-r-md);background:var(--st-surface-2)}
.st-collapse>summary{list-style:none;display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;font-size:var(--st-fs-sm);font-weight:500;color:var(--st-ink-muted)}
.st-collapse>summary::-webkit-details-marker{display:none}
.st-collapse>summary .st-chev{margin-left:auto;transition:transform var(--st-dur) var(--st-ease)}
.st-collapse[open]>summary .st-chev{transform:rotate(180deg)}
.st-collapse__body{padding:0 14px 14px;font-size:var(--st-fs-sm);color:var(--st-ink-muted);line-height:1.6}
/* code */
.st-code{border-radius:var(--st-r-md);background:var(--st-code-bg);color:var(--st-code-ink);overflow:hidden}
.st-code__head{display:flex;align-items:center;justify-content:space-between;padding:8px 8px 8px 14px;font:500 var(--st-fs-xs)/1 var(--st-font);color:#9aa3ab;border-bottom:1px solid rgba(255,255,255,.06)}
.st-code pre{margin:0;padding:14px;overflow:auto;font:400 13px/1.6 var(--st-font-mono)}
.st-code .st-btn--icon{color:#9aa3ab} .st-code .st-btn--icon:hover{background:rgba(255,255,255,.08);color:#fff}
/* composer */
.st-composer{display:flex;flex-direction:column;gap:8px;padding:10px;background:var(--st-surface);border:1px solid var(--st-line);border-radius:var(--st-r-lg);box-shadow:var(--st-sh-bar)}
.st-composer:focus-within{border-color:var(--st-focus)}
.st-composer textarea{border:0;outline:0;resize:none;background:transparent;color:var(--st-ink);font:400 var(--st-fs-base)/1.5 var(--st-font);padding:6px 8px;min-height:48px}
.st-composer__bar{display:flex;align-items:center;gap:6px}
.st-composer__bar .spacer{flex:1}
/* drawer */
.st-drawer{position:fixed;top:0;right:0;bottom:0;width:360px;max-width:100vw;background:var(--st-surface);border-left:1px solid var(--st-line);box-shadow:var(--st-sh-pop);display:flex;flex-direction:column;transform:translateX(100%);transition:transform 220ms var(--st-ease)}
.st-drawer[data-open="true"]{transform:none}
.st-drawer__head{display:flex;align-items:center;justify-content:space-between;padding:18px 20px;border-bottom:1px solid var(--st-line)}
.st-drawer__body{padding:20px;display:flex;flex-direction:column;gap:22px;overflow:auto}
/* buttons */
.st-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:42px;padding:0 18px;border-radius:var(--st-r-md);border:0;font:700 var(--st-fs-md)/1 var(--st-font);cursor:pointer;transition:background var(--st-dur),transform 120ms}
.st-btn:active{transform:translateY(1px)}
.st-btn--primary{background:var(--st-accent);color:var(--st-accent-ink);box-shadow:0 6px 16px rgba(16,185,129,.24),inset 0 1px 0 rgba(255,255,255,.18)}
.st-btn--primary:hover{background:var(--st-accent-hover)}
.st-btn--secondary{background:transparent;color:var(--st-ink);border:1.5px solid var(--st-line)}
.st-btn--secondary:hover{background:var(--st-surface-2)}
.st-btn--danger{background:var(--st-danger-tint);color:var(--st-danger-text)}
.st-btn--icon{width:36px;height:36px;padding:0;border-radius:10px;background:transparent;color:var(--st-ink-muted)}
.st-btn--icon:hover{background:var(--st-surface-2);color:var(--st-ink)}
.st-btn:disabled{opacity:.45;cursor:not-allowed}
/* form controls */
.st-field{display:flex;flex-direction:column;gap:8px}
.st-label{display:flex;justify-content:space-between;font-size:var(--st-fs-sm);font-weight:500;color:var(--st-ink-soft)}
.st-label output{font-variant-numeric:tabular-nums;color:var(--st-ink-muted)}
.st-input{height:44px;padding:0 14px;border-radius:var(--st-r-md);border:1.5px solid var(--st-line);background:var(--st-surface);color:var(--st-ink);font:400 var(--st-fs-md) var(--st-font)}
.st-input:focus{outline:0;border-color:var(--st-focus);box-shadow:0 0 0 3px color-mix(in srgb,var(--st-focus) 20%,transparent)}
.st-slider{width:100%;accent-color:var(--st-accent)}
.st-toggle{position:relative;width:40px;height:24px;border-radius:var(--st-r-pill);border:0;background:var(--st-line);cursor:pointer;transition:background var(--st-dur)}
.st-toggle::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform var(--st-dur) var(--st-ease)}
.st-toggle[aria-checked="true"]{background:var(--st-accent)}
.st-toggle[aria-checked="true"]::after{transform:translateX(16px)}
/* toast / alert */
.st-toast{display:flex;align-items:flex-start;gap:12px;padding:14px 16px;border-radius:var(--st-r-md);background:var(--st-surface);border:1px solid var(--st-line);box-shadow:var(--st-sh-pop);font-size:var(--st-fs-md);color:var(--st-ink-soft);max-width:420px}
.st-toast__title{font-weight:700;color:var(--st-ink)}
.st-toast--info .st-icon{color:var(--st-info)}
.st-toast--success .st-icon{color:var(--st-accent)}
.st-toast--warn .st-icon{color:var(--st-warn)}
.st-toast--error .st-icon{color:var(--st-danger)}
.st-toasts{position:fixed;right:20px;bottom:20px;display:flex;flex-direction:column;gap:10px;z-index:50}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+213
View File
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Strata</title>
<link rel="stylesheet" href="/web/tokens.css">
<link rel="stylesheet" href="/web/components.css">
<link rel="stylesheet" href="/web/app.css">
<script>
// the theme before the first paint: the saved choice, else the system's
try {
const t = localStorage.getItem("strata.theme") ||
(matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
</head>
<body>
<header class="st-header">
<span class="st-header__brand">Strata</span>
<nav class="st-tabs" role="tablist" aria-label="Views">
<button class="st-tab" role="tab" id="tab-btn-chat" aria-controls="view-chat" aria-selected="true" data-tab="chat">
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-chat"/></svg>Chat</button>
<button class="st-tab" role="tab" id="tab-btn-monitor" aria-controls="view-monitor" aria-selected="false" data-tab="monitor">
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-activity"/></svg>Monitor</button>
<button class="st-tab" role="tab" id="tab-btn-about" aria-controls="view-about" aria-selected="false" data-tab="about">
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-info"/></svg>About</button>
</nav>
<span class="st-header__spacer"></span>
<span class="st-pill" id="pill" data-state="idle" role="status" aria-live="polite">
<span class="st-pill__dot"></span><span id="pill-text">Connecting&hellip;</span></span>
<button class="st-btn st-btn--icon" id="theme-btn" title="Light / dark" aria-label="Switch between light and dark">
<svg class="st-icon"><use id="theme-icon" href="/web/sprite.svg#i-moon"/></svg></button>
</header>
<main>
<!-- ============================ Chat ============================ -->
<section class="view view--chat" id="view-chat" role="tabpanel" aria-labelledby="tab-btn-chat">
<div class="chat-scroll" id="chat-scroll">
<div class="chat" id="chat">
<div class="chat-empty" id="chat-empty">
<div class="chat-empty__title">Ask anything</div>
<div class="chat-empty__sub" id="chat-empty-sub">The model runs on this PC. Nothing leaves it.</div>
</div>
</div>
</div>
<div class="composer-wrap">
<form class="st-composer" id="composer">
<div class="attachments" id="attachments" hidden></div>
<textarea id="input" rows="2" placeholder="Ask anything&hellip;" aria-label="Message"></textarea>
<div class="st-composer__bar">
<button type="button" class="st-btn st-btn--icon" id="attach-btn" title="Attach a text file (or drop it here)" aria-label="Attach a file">
<svg class="st-icon"><use href="/web/sprite.svg#i-attach"/></svg></button>
<input type="file" id="file" multiple hidden>
<button type="button" class="st-btn st-btn--icon" id="new-btn" title="New chat" aria-label="New chat">
<svg class="st-icon"><use href="/web/sprite.svg#i-new-chat"/></svg></button>
<button type="button" class="st-btn st-btn--icon" id="export-btn" title="Save this chat as Markdown" aria-label="Save this chat">
<svg class="st-icon"><use href="/web/sprite.svg#i-download"/></svg></button>
<button type="button" class="st-btn st-btn--icon" id="sampling-btn" title="Sampling and thinking" aria-label="Sampling and thinking">
<svg class="st-icon"><use href="/web/sprite.svg#i-settings"/></svg></button>
<span class="spacer"></span>
<span class="composer-hint" id="composer-hint"></span>
<button type="button" class="st-btn st-btn--secondary" id="stop-btn" hidden>
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-stop"/></svg>Stop</button>
<button type="submit" class="st-btn st-btn--primary st-btn--send" id="send-btn" aria-label="Send">
<svg class="st-icon"><use href="/web/sprite.svg#i-send"/></svg></button>
</div>
</form>
</div>
</section>
<!-- ============================ Monitor ============================ -->
<section class="view view--monitor" id="view-monitor" role="tabpanel" aria-labelledby="tab-btn-monitor" hidden>
<div class="monitor">
<div class="st-card state-card">
<div class="state-card__head">
<span class="card-title">Model state</span>
<span class="badges" id="state-badges">
<span class="st-badge" data-s="idle">Idle</span>
<span class="st-badge st-badge--reading" data-s="reading">Reading</span>
<span class="st-badge st-badge--generating" data-s="generating">Generating</span>
<span class="st-badge st-badge--queued" data-s="queued">Queued</span>
<span class="st-badge st-badge--error" data-s="error">Error</span>
</span>
</div>
<div class="state-card__row"><span id="state-label">Waiting for a request</span><span class="muted" id="state-detail"></span></div>
<div class="st-progress" id="state-progress"><div class="st-progress__bar" id="state-bar" style="width:0%"></div></div>
</div>
<div class="metrics" id="metrics">
<!-- filled by app.js: Speed, GPU load, VRAM, GPU temp, Power, PCIe, CPU, Disk read -->
</div>
<div class="monitor-row">
<div class="st-card ctx-card">
<span class="card-title">Context fill</span>
<div class="ctx-gauge">
<svg class="st-gauge" viewBox="0 0 120 120" aria-hidden="true">
<circle class="st-gauge__track" cx="60" cy="60" r="50" fill="none" stroke-width="10" stroke-linecap="round"
stroke-dasharray="235.6 314.2" transform="rotate(135 60 60)"/>
<circle class="st-gauge__fill" id="ctx-fill" cx="60" cy="60" r="50" fill="none" stroke-width="10"
stroke-linecap="round" stroke-dasharray="0 314.2" transform="rotate(135 60 60)"/>
</svg>
<div class="ctx-gauge__label"><span class="ctx-gauge__pct" id="ctx-pct">0%</span><span class="muted" id="ctx-sub">&ndash;</span></div>
</div>
<div class="bar-row"><span>Experts in VRAM</span><span class="muted" id="slots-text">&ndash;</span></div>
<div class="st-progress"><div class="st-progress__bar" id="slots-bar" style="width:0%"></div></div>
<div class="bar-row"><span>System RAM</span><span class="muted" id="ram-text">&ndash;</span></div>
<div class="st-progress" id="ram-progress"><div class="st-progress__bar" id="ram-bar" style="width:0%"></div></div>
<div class="bar-row"><span>GPU temperature</span><span class="muted" id="temp-text">&ndash;</span></div>
<div class="st-progress" data-tone="warn"><div class="st-progress__bar" id="temp-bar" style="width:0%"></div></div>
</div>
<div class="st-card req-card">
<div class="req-head"><span class="card-title">Recent requests</span>
<button class="st-btn st-btn--secondary" id="req-all" hidden>Show all</button></div>
<div class="table-wrap" id="req-wrap">
<table class="st-table">
<thead><tr><th>Time</th><th>Status</th><th class="num">Prompt</th><th class="num">Reused</th><th class="num">Output</th>
<th class="num">Tok/s</th><th class="num">Duration</th></tr></thead>
<tbody id="req-body"><tr><td colspan="7" class="muted">No requests yet</td></tr></tbody>
</table>
</div>
<p class="muted req-totals" id="req-totals"></p>
</div>
</div>
</div>
</section>
<!-- ============================ About ============================ -->
<section class="view view--about" id="view-about" role="tabpanel" aria-labelledby="tab-btn-about" hidden>
<div class="about">
<div class="st-card">
<span class="card-title">Model and engine</span>
<dl class="facts" id="facts-engine"></dl>
</div>
<div class="st-card">
<span class="card-title">This PC</span>
<dl class="facts" id="facts-hw"></dl>
</div>
<div class="st-card">
<span class="card-title">Connect your tools</span>
<p class="muted">Any OpenAI- or Anthropic-compatible client works with these addresses.</p>
<dl class="facts" id="facts-api"></dl>
</div>
<div class="st-card">
<span class="card-title">Settings</span>
<div class="st-field">
<label class="st-label" for="api-key">API key <output>only if the server was started with one</output></label>
<input class="st-input" id="api-key" type="password" autocomplete="off" placeholder="Not needed">
</div>
<div class="toggle-row"><span>Dark theme</span>
<button class="st-toggle" id="dark-toggle" role="switch" aria-checked="false" aria-label="Dark theme"></button></div>
<p class="muted small">Chats, settings and the key are kept in this browser only.
<a href="https://github.com/Niko1221/Strata" target="_blank" rel="noopener noreferrer">Strata on GitHub</a></p>
</div>
</div>
</section>
</main>
<!-- ============================ Sampling drawer ============================ -->
<aside class="st-drawer" id="drawer" data-open="false" aria-label="Sampling and thinking" aria-hidden="true">
<div class="st-drawer__head"><span class="card-title">Sampling</span>
<button class="st-btn st-btn--icon" id="drawer-close" aria-label="Close">
<svg class="st-icon"><use href="/web/sprite.svg#i-chevron"/></svg></button></div>
<div class="st-drawer__body">
<div class="st-field">
<label class="st-label" for="s-thinking">Thinking <output id="o-thinking"></output></label>
<div class="seg" id="s-thinking" role="radiogroup" aria-label="Thinking">
<button type="button" role="radio" data-v="none">Off</button><button type="button" role="radio" data-v="low">Low</button>
<button type="button" role="radio" data-v="medium">Medium</button><button type="button" role="radio" data-v="high">High</button>
</div>
</div>
<div class="st-field">
<label class="st-label" for="s-temp">Temperature <output id="o-temp"></output></label>
<input class="st-slider" type="range" id="s-temp" min="0" max="1.5" step="0.05">
<span class="muted small">0 = always the most likely word (exact, repeatable)</span>
</div>
<div class="st-field">
<label class="st-label" for="s-topp">Top-p <output id="o-topp"></output></label>
<input class="st-slider" type="range" id="s-topp" min="0.05" max="1" step="0.05">
</div>
<div class="st-field">
<label class="st-label" for="s-topk">Top-k <output id="o-topk"></output></label>
<input class="st-slider" type="range" id="s-topk" min="1" max="64" step="1">
</div>
<div class="st-field">
<label class="st-label" for="s-max">Max tokens <output>empty = until done</output></label>
<input class="st-input" id="s-max" type="number" min="1" step="1" placeholder="Until done">
</div>
<div class="st-field">
<label class="st-label" for="s-seed">Seed <output>empty = random</output></label>
<input class="st-input" id="s-seed" type="number" min="1" step="1" placeholder="Random">
</div>
<div class="toggle-row"><span>Show thinking<br><span class="muted small">expanded while it streams</span></span>
<button class="st-toggle" id="s-show" role="switch" aria-checked="true" aria-label="Show thinking"></button></div>
<div class="toggle-row" id="esp-row" hidden><span>Experimental speed projection<br><span class="muted small">the
engine's control vector; off = the stock model. Switching reads the chat again once</span></span>
<button class="st-toggle" id="s-esp" role="switch" aria-checked="true" aria-label="Experimental speed projection"></button></div>
<div class="toggle-row"><span>Use for other apps too<br><span class="muted small">omp and other API clients get
these settings for anything they don't set themselves</span></span>
<button class="st-toggle" id="s-share" role="switch" aria-checked="false" aria-label="Use for other apps too"></button></div>
<div class="drawer-actions">
<button class="st-btn st-btn--secondary" id="s-reset" type="button">Reset</button>
<button class="st-btn st-btn--primary" id="s-apply" type="button">Apply</button>
</div>
</div>
</aside>
<div class="scrim" id="scrim" hidden></div>
<div class="st-toasts" id="toasts" aria-live="polite"></div>
<script src="/web/app.js"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYToyMjRkYzc2Ny1hNTJmLTRhNTgtYmRhNi0wNjNjZjgxZDE0ZDkAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaFpCcvmnJ1xuOVFbOGxO7IQAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpjZTVmMTYyMy1hMGY4LTRjNDgtYmQ1MC0yOGRhYTVkZGQ3OWZscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNohRiWe4A1zTyq58r9HwVbdgAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggvnkoUEWEbTHaYh6o+DzKs/DGvSaEkSiXbTkyg+EGZBWkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaPM+mDLLCljfKcnx6K3jLsYAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCDyyT5tVoB8Cn5pGifFieqsDs7yYczybQ255HiPUQvocGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBh8Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjIyNGRjNzY3LWE1MmYtNGE1OC1iZGE2LTA2M2NmODFkMTRkOS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjYxMTcxNDYxLWZjODUtNDNkYi05NzdkLTVhNTQ0NWMwNzY0OXJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCC+eShQRYRtMdpiHqj4PMqz8Ma9JoSRKJdtOTKD4QZkFaJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggzN40WhHml0Alrpq9IN+inEViO5AnmGuZkyahASHk13qiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg1QfqlfahOW+5OQ+LKm+K4e2m36U3CKHsYWKitMx8+Mt0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
<symbol id="i-chat" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M21 11.5C21 16.194 16.971 20 12 20C10.8 20 9.65 19.78 8.6 19.37L4 20.5L5.2 16.8C3.83 15.34 3 13.5 3 11.5C3 6.806 7.029 3 12 3C16.971 3 21 6.806 21 11.5Z" fill="currentColor"></path><path d="M21 11.5C21 16.194 16.971 20 12 20C10.8 20 9.65 19.78 8.6 19.37L4 20.5L5.2 16.8C3.83 15.34 3 13.5 3 11.5C3 6.806 7.029 3 12 3C16.971 3 21 6.806 21 11.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M8 11.5H8.01M12 11.5H12.01M16 11.5H16.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-activity" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M2 12C2 7.286 2 4.929 3.464 3.464C4.929 2 7.286 2 12 2C16.714 2 19.071 2 20.536 3.464C22 4.929 22 7.286 22 12C22 16.714 22 19.071 20.536 20.536C19.071 22 16.714 22 12 22C7.286 22 4.929 22 3.464 20.536C2 19.071 2 16.714 2 12Z" fill="currentColor"></path><path d="M2 12C2 7.286 2 4.929 3.464 3.464C4.929 2 7.286 2 12 2C16.714 2 19.071 2 20.536 3.464C22 4.929 22 7.286 22 12C22 16.714 22 19.071 20.536 20.536C19.071 22 16.714 22 12 22C7.286 22 4.929 22 3.464 20.536C2 19.071 2 16.714 2 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 12.5H8.5L10.5 8L13.5 16L15.5 11.5H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M12.22 2H11.78A2 2 0 0 0 9.78 4V4.18A2 2 0 0 1 8.78 5.91L8.35 6.16A2 2 0 0 1 6.35 6.16L6.2 6.08A2 2 0 0 0 3.47 6.81L3.25 7.19A2 2 0 0 0 3.98 9.92L4.13 10.02A2 2 0 0 1 5.13 11.74V12.25A2 2 0 0 1 4.13 13.99L3.98 14.08A2 2 0 0 0 3.25 16.81L3.47 17.19A2 2 0 0 0 6.2 17.92L6.35 17.84A2 2 0 0 1 8.35 17.84L8.78 18.09A2 2 0 0 1 9.78 19.82V20A2 2 0 0 0 11.78 22H12.22A2 2 0 0 0 14.22 20V19.82A2 2 0 0 1 15.22 18.09L15.65 17.84A2 2 0 0 1 17.65 17.84L17.8 17.92A2 2 0 0 0 20.53 17.19L20.75 16.8A2 2 0 0 0 20.02 14.07L19.87 13.99A2 2 0 0 1 18.87 12.25V11.75A2 2 0 0 1 19.87 10.01L20.02 9.92A2 2 0 0 0 20.75 7.19L20.53 6.81A2 2 0 0 0 17.8 6.08L17.65 6.16A2 2 0 0 1 15.65 6.16L15.22 5.91A2 2 0 0 1 14.22 4.18V4A2 2 0 0 0 12.22 2Z" fill="currentColor"></path><path d="M12.22 2H11.78A2 2 0 0 0 9.78 4V4.18A2 2 0 0 1 8.78 5.91L8.35 6.16A2 2 0 0 1 6.35 6.16L6.2 6.08A2 2 0 0 0 3.47 6.81L3.25 7.19A2 2 0 0 0 3.98 9.92L4.13 10.02A2 2 0 0 1 5.13 11.74V12.25A2 2 0 0 1 4.13 13.99L3.98 14.08A2 2 0 0 0 3.25 16.81L3.47 17.19A2 2 0 0 0 6.2 17.92L6.35 17.84A2 2 0 0 1 8.35 17.84L8.78 18.09A2 2 0 0 1 9.78 19.82V20A2 2 0 0 0 11.78 22H12.22A2 2 0 0 0 14.22 20V19.82A2 2 0 0 1 15.22 18.09L15.65 17.84A2 2 0 0 1 17.65 17.84L17.8 17.92A2 2 0 0 0 20.53 17.19L20.75 16.8A2 2 0 0 0 20.02 14.07L19.87 13.99A2 2 0 0 1 18.87 12.25V11.75A2 2 0 0 1 19.87 10.01L20.02 9.92A2 2 0 0 0 20.75 7.19L20.53 6.81A2 2 0 0 0 17.8 6.08L17.65 6.16A2 2 0 0 1 15.65 6.16L15.22 5.91A2 2 0 0 1 14.22 4.18V4A2 2 0 0 0 12.22 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-gpu" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M2 7.5C2 6.12 3.12 5 4.5 5H19.5C20.88 5 22 6.12 22 7.5V15.5C22 16.88 20.88 18 19.5 18H4.5C3.12 18 2 16.88 2 15.5V7.5Z" fill="currentColor"></path><path d="M2 7.5C2 6.12 3.12 5 4.5 5H19.5C20.88 5 22 6.12 22 7.5V15.5C22 16.88 20.88 18 19.5 18H4.5C3.12 18 2 16.88 2 15.5V7.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12.5 11.5C12.5 13.433 10.933 15 9 15C7.067 15 5.5 13.433 5.5 11.5C5.5 9.567 7.067 8 9 8C10.933 8 12.5 9.567 12.5 11.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16 9V14M19 9V14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 18V20.5M10 18V20.5M14 18V20.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M6 9C6 7.343 7.343 6 9 6H15C16.657 6 18 7.343 18 9V15C18 16.657 16.657 18 15 18H9C7.343 18 6 16.657 6 15V9Z" fill="currentColor"></path><path d="M6 9C6 7.343 7.343 6 9 6H15C16.657 6 18 7.343 18 9V15C18 16.657 16.657 18 15 18H9C7.343 18 6 16.657 6 15V9Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 11C10 10.448 10.448 10 11 10H13C13.552 10 14 10.448 14 11V13C14 13.552 13.552 14 13 14H11C10.448 14 10 13.552 10 13V11Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.5 2.5V6M14.5 2.5V6M9.5 18V21.5M14.5 18V21.5M2.5 9.5H6M2.5 14.5H6M18 9.5H21.5M18 14.5H21.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-memory" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M2 8C2 6.895 2.895 6 4 6H20C21.105 6 22 6.895 22 8V15H2V8Z" fill="currentColor"></path><path d="M2 8C2 6.895 2.895 6 4 6H20C21.105 6 22 6.895 22 8V15H2V8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 9.5V11.5M10 9.5V11.5M14 9.5V11.5M18 9.5V11.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4 15V18M8 15V18M12 15V18M16 15V18M20 15V18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-disk" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M2 9C2 7.343 3.343 6 5 6H19C20.657 6 22 7.343 22 9V15C22 16.657 20.657 18 19 18H5C3.343 18 2 16.657 2 15V9Z" fill="currentColor"></path><path d="M2 9C2 7.343 3.343 6 5 6H19C20.657 6 22 7.343 22 9V15C22 16.657 20.657 18 19 18H5C3.343 18 2 16.657 2 15V9Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M6 12H12.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17.5 12H17.51" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M14 14.76V5C14 3.895 13.105 3 12 3C10.895 3 10 3.895 10 5V14.76C8.8 15.45 8 16.73 8 18.2C8 20.4 9.8 22 12 22C14.2 22 16 20.4 16 18.2C16 16.73 15.2 15.45 14 14.76Z" fill="currentColor"></path><path d="M14 14.76V5C14 3.895 13.105 3 12 3C10.895 3 10 3.895 10 5V14.76C8.8 15.45 8 16.73 8 18.2C8 20.4 9.8 22 12 22C14.2 22 16 20.4 16 18.2C16 16.73 15.2 15.45 14 14.76Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 17.5V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M13 2L4 14H11L10 22L20 9.5H13L13 2Z" fill="currentColor"></path><path d="M13 2L4 14H11L10 22L20 9.5H13L13 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M8.46 20.06L14.26 14.26A3.2 3.2 0 0 0 9.74 9.74L3.94 15.54A3.2 3.2 0 0 0 8.46 20.06Z" fill="currentColor"></path><path d="M10 13C10.43 13.57 10.98 14.05 11.61 14.39C12.24 14.74 12.93 14.94 13.65 14.99C14.36 15.04 15.08 14.94 15.75 14.69C16.42 14.44 17.03 14.05 17.54 13.54L20.54 10.54C21.45 9.6 21.95 8.33 21.94 7.02C21.93 5.71 21.41 4.46 20.48 3.53C19.55 2.6 18.3 2.08 16.99 2.07C15.68 2.06 14.41 2.56 13.47 3.47L11.75 5.18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M14 11C13.57 10.43 13.02 9.95 12.39 9.61C11.76 9.26 11.07 9.06 10.35 9.01C9.64 8.96 8.92 9.06 8.25 9.31C7.58 9.56 6.97 9.95 6.46 10.46L3.46 13.46C2.55 14.4 2.05 15.67 2.06 16.98C2.07 18.29 2.59 19.54 3.52 20.47C4.45 21.4 5.7 21.92 7.01 21.93C8.32 21.94 9.59 21.44 10.53 20.53L12.24 18.82" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" fill="currentColor"></path><path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 7V12L15 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M2 15C2 9.477 6.477 5 12 5C17.523 5 22 9.477 22 15C22 16.5 21.7 17.8 21.1 19H2.9C2.3 17.8 2 16.5 2 15Z" fill="currentColor"></path><path d="M2 15C2 9.477 6.477 5 12 5C17.523 5 22 9.477 22 15C22 16.5 21.7 17.8 21.1 19H2.9C2.3 17.8 2 16.5 2 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 15L16 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M13 15C13 15.552 12.552 16 12 16C11.448 16 11 15.552 11 15C11 14.448 11.448 14 12 14C12.552 14 13 14.448 13 15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-layers" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M12 3L22 8L12 13L2 8L12 3Z" fill="currentColor"></path><path d="M12 3L22 8L12 13L2 8L12 3Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M2 16L12 21L22 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-experts" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M3 5.5C3 4.119 4.119 3 5.5 3H6.5C7.881 3 9 4.119 9 5.5V6.5C9 7.881 7.881 9 6.5 9H5.5C4.119 9 3 7.881 3 6.5V5.5Z" fill="currentColor"></path><path opacity="0.12" d="M15 17.5C15 16.119 16.119 15 17.5 15H18.5C19.881 15 21 16.119 21 17.5V18.5C21 19.881 19.881 21 18.5 21H17.5C16.119 21 15 19.881 15 18.5V17.5Z" fill="currentColor"></path><path d="M3 5.5C3 4.119 4.119 3 5.5 3H6.5C7.881 3 9 4.119 9 5.5V6.5C9 7.881 7.881 9 6.5 9H5.5C4.119 9 3 7.881 3 6.5V5.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M15 5.5C15 4.119 16.119 3 17.5 3H18.5C19.881 3 21 4.119 21 5.5V6.5C21 7.881 19.881 9 18.5 9H17.5C16.119 9 15 7.881 15 6.5V5.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 17.5C3 16.119 4.119 15 5.5 15H6.5C7.881 15 9 16.119 9 17.5V18.5C9 19.881 7.881 21 6.5 21H5.5C4.119 21 3 19.881 3 18.5V17.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M15 17.5C15 16.119 16.119 15 17.5 15H18.5C19.881 15 21 16.119 21 17.5V18.5C21 19.881 19.881 21 18.5 21H17.5C16.119 21 15 19.881 15 18.5V17.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-send" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M21 3L2 10L10 13.5L13.5 21L21 3Z" fill="currentColor"></path><path d="M21 3L2 10L10 13.5L13.5 21L21 3Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 13.5L21 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-stop" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M5 8C5 6.343 6.343 5 8 5H16C17.657 5 19 6.343 19 8V16C19 17.657 17.657 19 16 19H8C6.343 19 5 17.657 5 16V8Z" fill="currentColor"></path><path d="M5 8C5 6.343 6.343 5 8 5H16C17.657 5 19 6.343 19 8V16C19 17.657 17.657 19 16 19H8C6.343 19 5 17.657 5 16V8Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-attach" viewBox="0 0 24 24" fill="none"><path d="M21.44 11.05L12.25 20.24A6 6 0 0 1 3.76 11.75L12.33 3.18A4 4 0 1 1 18 8.84L9.41 17.41A2 2 0 0 1 6.58 14.58L15.07 6.1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-image" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M3 7C3 4.791 4.791 3 7 3H17C19.209 3 21 4.791 21 7V17C21 19.209 19.209 21 17 21H7C4.791 21 3 19.209 3 17V7Z" fill="currentColor"></path><path d="M3 7C3 4.791 4.791 3 7 3H17C19.209 3 21 4.791 21 7V17C21 19.209 19.209 21 17 21H7C4.791 21 3 19.209 3 17V7Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 8.5C10 9.328 9.328 10 8.5 10C7.672 10 7 9.328 7 8.5C7 7.672 7.672 7 8.5 7C9.328 7 10 7.672 10 8.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3.5 17.5L8 13L11 16L15 12L20.5 17.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-copy" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M8 10C8 8.895 8.895 8 10 8H18C19.105 8 20 8.895 20 10V18C20 19.105 19.105 20 18 20H10C8.895 20 8 19.105 8 18V10Z" fill="currentColor"></path><path d="M8 10C8 8.895 8.895 8 10 8H18C19.105 8 20 8.895 20 10V18C20 19.105 19.105 20 18 20H10C8.895 20 8 19.105 8 18V10Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16 8V6C16 4.895 15.105 4 14 4H6C4.895 4 4 4.895 4 6V14C4 15.105 4.895 16 6 16H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none"><path d="M4.5 12.5L9.5 17.5L19.5 6.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-trash" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M5 6L6 19C6.08 20.1 7 21 8.1 21H15.9C17 21 17.92 20.1 18 19L19 6H5Z" fill="currentColor"></path><path d="M5 6L6 19C6.08 20.1 7 21 8.1 21H15.9C17 21 17.92 20.1 18 19L19 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M3 6H21" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 6V4.5C9 3.672 9.672 3 10.5 3H13.5C14.328 3 15 3.672 15 4.5V6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 10.5V16.5M14 10.5V16.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-new-chat" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M21 11.5C21 16.194 16.971 20 12 20C10.8 20 9.65 19.78 8.6 19.37L4 20.5L5.2 16.8C3.83 15.34 3 13.5 3 11.5C3 6.806 7.029 3 12 3C16.971 3 21 6.806 21 11.5Z" fill="currentColor"></path><path d="M21 11.5C21 16.194 16.971 20 12 20C10.8 20 9.65 19.78 8.6 19.37L4 20.5L5.2 16.8C3.83 15.34 3 13.5 3 11.5C3 6.806 7.029 3 12 3C16.971 3 21 6.806 21 11.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 8.5V14.5M9 11.5H15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-thinking" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M12 2.5C8.41 2.5 5.5 5.41 5.5 9C5.5 11.3 6.7 13.3 8.5 14.5V16.5C8.5 17.05 8.95 17.5 9.5 17.5H14.5C15.05 17.5 15.5 17.05 15.5 16.5V14.5C17.3 13.3 18.5 11.3 18.5 9C18.5 5.41 15.59 2.5 12 2.5Z" fill="currentColor"></path><path d="M12 2.5C8.41 2.5 5.5 5.41 5.5 9C5.5 11.3 6.7 13.3 8.5 14.5V16.5C8.5 17.05 8.95 17.5 9.5 17.5H14.5C15.05 17.5 15.5 17.05 15.5 16.5V14.5C17.3 13.3 18.5 11.3 18.5 9C18.5 5.41 15.59 2.5 12 2.5Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9.5 20.5H14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-tool" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M14.7 6.3A1 1 0 0 0 14.7 7.7L16.3 9.3A1 1 0 0 0 17.7 9.3L21.47 5.53A6 6 0 0 1 13.53 13.47L6.62 20.38A2.12 2.12 0 0 1 3.62 17.38L10.53 10.47A6 6 0 0 1 18.47 2.53L14.71 6.29Z" fill="currentColor"></path><path d="M14.7 6.3A1 1 0 0 0 14.7 7.7L16.3 9.3A1 1 0 0 0 17.7 9.3L21.47 5.53A6 6 0 0 1 13.53 13.47L6.62 20.38A2.12 2.12 0 0 1 3.62 17.38L10.53 10.47A6 6 0 0 1 18.47 2.53L14.71 6.29Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none"><path d="M4 15V17C4 18.657 5.343 20 7 20H17C18.657 20 20 18.657 20 17V15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 4V14M12 14L8 10M12 14L16 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-warning" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M10.24 3.99L2.4 17.5C1.62 18.83 2.58 20.5 4.12 20.5H19.88C21.42 20.5 22.38 18.83 21.6 17.5L13.76 3.99C12.98 2.66 11.02 2.66 10.24 3.99Z" fill="currentColor"></path><path d="M10.24 3.99L2.4 17.5C1.62 18.83 2.58 20.5 4.12 20.5H19.88C21.42 20.5 22.38 18.83 21.6 17.5L13.76 3.99C12.98 2.66 11.02 2.66 10.24 3.99Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 9V13M12 16.5H12.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-error" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" fill="currentColor"></path><path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M15 9L9 15M9 9L15 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" fill="currentColor"></path><path d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 11V16.5M12 7.5H12.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none"><path d="M21 12A9 9 0 1 1 18.36 5.64L21 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 3V8H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-sun" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M16 12C16 14.209 14.209 16 12 16C9.791 16 8 14.209 8 12C8 9.791 9.791 8 12 8C14.209 8 16 9.791 16 12Z" fill="currentColor"></path><path d="M16 12C16 14.209 14.209 16 12 16C9.791 16 8 14.209 8 12C8 9.791 9.791 8 12 8C14.209 8 16 9.791 16 12Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 2V4M12 20V22M4.93 4.93L6.34 6.34M17.66 17.66L19.07 19.07M2 12H4M20 12H22M4.93 19.07L6.34 17.66M17.66 6.34L19.07 4.93" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none"><path opacity="0.12" d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79Z" fill="currentColor"></path><path d="M21 12.79A9 9 0 1 1 11.21 3A7 7 0 0 0 21 12.79Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

+87
View File
@@ -0,0 +1,87 @@
/* Strata UI — tokens. Derived from the Gainsty design system.
Light is default; dark via <html data-theme="dark">.
Values marked (derived) are not in the Gainsty source and were added for the app. */
/* Outfit (SIL Open Font License 1.1, serve/web/fonts/OFL.txt): one variable font, weights 100-900 */
@font-face{font-family:"Outfit";src:url("/fonts/outfit-latin-wght.woff2") format("woff2-variations");font-weight:100 900;font-display:swap;unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:"Outfit";src:url("/fonts/outfit-latin-ext-wght.woff2") format("woff2-variations");font-weight:100 900;font-display:swap;unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
:root{
/* type */
--st-font:"Outfit",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
--st-font-mono:ui-monospace,"SF Mono","Cascadia Code",Consolas,"Liberation Mono",monospace; /* (derived) */
--st-fs-xs:12px; --st-fs-sm:13px; --st-fs-md:14px; --st-fs-base:16px;
--st-fs-lg:18px; --st-fs-xl:24px; --st-fs-stat:32px; --st-fs-display:40px;
--st-fw-regular:400; --st-fw-medium:500; --st-fw-bold:700; --st-fw-black:900;
--st-lh-tight:1.1; --st-lh-body:1.55;
/* spacing (4pt) */
--st-s-1:4px; --st-s-2:8px; --st-s-3:12px; --st-s-4:16px; --st-s-5:20px;
--st-s-6:24px; --st-s-8:32px; --st-s-10:40px; --st-s-12:48px; --st-s-16:64px;
/* radius */
--st-r-sm:8px; --st-r-md:14px; --st-r-lg:20px; --st-r-xl:24px; --st-r-pill:9999px;
/* motion (derived) */
--st-ease:cubic-bezier(.2,.7,.2,1); --st-dur:160ms;
/* color — light */
--st-bg:#f9fafb;
--st-surface:#ffffff;
--st-surface-2:#f7f7f7;
--st-line:#dee5ee;
--st-line-soft:#eeeeee;
--st-ink:#221e1f;
--st-ink-soft:#303030;
--st-ink-muted:#6f6f6f; /* Gainsty #7a7a7a darkened slightly for 4.5:1 on white */
--st-icon:#2F384C; /* Anron default */
--st-accent:#10b981;
--st-accent-hover:#0c8a60;
--st-accent-ink:#ffffff;
--st-accent-tint:#ecfdf5;
--st-accent-text:#0c8a60; /* accent used as text on light */
--st-focus:#405de6;
--st-info:#0f97ff; --st-info-tint:#e8f4ff; --st-info-text:#0668c2;
--st-warn:#e39b0b; --st-warn-tint:#fff6e0; --st-warn-text:#8a5a00; /* (derived) */
--st-danger:#e0283f; --st-danger-tint:#fdecee; --st-danger-text:#b01a2e; /* (derived) */
--st-code-bg:#1b1f23; --st-code-ink:#e6e9ec; /* code blocks stay dark in both themes (derived) */
--st-sh-card:0 18px 7px rgba(0,0,0,.01),0 10px 6px rgba(0,0,0,.02),0 4px 4px rgba(0,0,0,.03),0 1px 2px rgba(0,0,0,.04);
--st-sh-bar:0 54px 21px rgba(0,0,0,.01),0 30px 18px rgba(0,0,0,.03),0 13px 13px rgba(0,0,0,.05),0 3px 7px rgba(0,0,0,.06);
--st-sh-pop:0 10px 40px rgba(15,23,42,.08),0 1px 3px rgba(15,23,42,.06);
color-scheme:light;
}
/* color — dark (all derived) */
[data-theme="dark"]{
--st-bg:#0e1113;
--st-surface:#161a1d;
--st-surface-2:#1d2226;
--st-line:#2a3036;
--st-line-soft:#22272c;
--st-ink:#eef1f3;
--st-ink-soft:#c7cdd3;
--st-ink-muted:#8e979f;
--st-icon:#c7cdd3;
--st-accent:#34d399;
--st-accent-hover:#4ade80;
--st-accent-ink:#052e20;
--st-accent-tint:rgba(52,211,153,.12);
--st-accent-text:#4ade80;
--st-focus:#7b93ff;
--st-info:#3aa9ff; --st-info-tint:rgba(58,169,255,.12); --st-info-text:#7cc4ff;
--st-warn:#f5b53d; --st-warn-tint:rgba(245,181,61,.12); --st-warn-text:#f8c96a;
--st-danger:#ff5a6e; --st-danger-tint:rgba(255,90,110,.12);--st-danger-text:#ff8a98;
--st-code-bg:#0a0c0e; --st-code-ink:#e6e9ec;
--st-sh-card:0 1px 2px rgba(0,0,0,.4);
--st-sh-bar:0 12px 32px rgba(0,0,0,.45),0 2px 6px rgba(0,0,0,.35);
--st-sh-pop:0 16px 48px rgba(0,0,0,.55),0 1px 3px rgba(0,0,0,.4);
color-scheme:dark;
}
+509 -45
View File
@@ -19,8 +19,10 @@ What the first run does (each step is skipped when it is already done):
6. prepares the model for Strata and fetches the MTP draft layer (~5 GB, from the original Qwen checkpoint)
7. writes run-<model>.bat / run-<model>.sh and starts the model
Options: --family qwen|swift, --model Q2_0|IQ2_XS|IQ3_XXS, --context 32768, --vision yes|no|gpu|cpu, --port 8080, --yes (recommended
Options: --family qwen|swift, --model Q2_0|IQ2_XS|IQ3_XXS|IQ3_S, --context 32768, --vision yes|no|gpu|cpu, --port 8080, --yes (recommended
answers, no questions), --setup (install another model / change settings instead of starting), --no-start,
--host 0.0.0.0 --api-key KEY (reach it from other devices on your network), --experimental-speed-projection on|off
(EXPERIMENTAL, off by default),
--models-dir DIR, --gguf-dir DIR (use GGUF files you already have), --build (compile instead of the ready-made
engine), --check (only check this PC).
"""
@@ -28,6 +30,7 @@ from __future__ import annotations
import argparse
import ctypes
import hashlib
import json
import os
import platform
@@ -54,15 +57,22 @@ PREBUILT_ASSET = "strata-windows-x64.zip" if WIN else "strata-linux-x64.zip"
# the CUDA libraries the ready-made engine loads (the same CUDA 13.0 it is built with), from NVIDIA's pip packages
CUDA_WHEELS = ["nvidia-cublas==13.0.2.14", "nvidia-cuda-runtime==13.0.96"]
MIN_DRIVER = 580 # CUDA 13.0
MIN_ENGINE = (0, 1, 1) # the ready-made engine that reads split models (Swift 1.5)
PY_PACKAGES = ["numpy", "jinja2", "regex", "pyyaml", "tqdm", "requests", "cmake", "ninja"]
MIN_ENGINE = (0, 1, 16) # v0.1.16: the Coder (256 experts, PR #54); v0.1.15: EAGER kernels; v0.1.14: #31
PY_PACKAGES = ["numpy", "jinja2", "regex", "pyyaml", "tqdm", "requests", "cmake", "ninja", "pillow", "psutil"]
MODELS = {
"Q2_0": {"about": "2-bit, the fastest", "download_gb": 66.4, "ram_gb": 48, "arena_gb": 34.0},
"IQ2_XS": {"about": "2-bit i-quant, a little better quality, close in speed", "download_gb": 68.0, "ram_gb": 48,
"arena_gb": 35.5},
"IQ3_XXS": {"about": "3-bit i-quant, the best quality, slower (more CPU work per token)", "download_gb": 75.8,
"IQ3_XXS": {"about": "3-bit i-quant, better quality, slower (more CPU work per token)", "download_gb": 75.8,
"ram_gb": 60, "arena_gb": 42.9},
# the original model only (Swift 1.5 has no IQ3_S): matches the full BF16 model on the published benchmarks
"IQ3_S": {"about": "3.5-bit i-quant, the best quality (matches the full model), the slowest; needs a 64 GB PC "
"with little else running", "download_gb": 83.6, "ram_gb": 62, "arena_gb": 50.3,
"families": ("qwen",)},
# the Coder release: 256 of the 512 experts kept (the ones code, tools and vision use), IQ2_S-IQ4_XS like IQ3_S
"IQ1_M": {"about": "the Coder's only size: half the experts, stored like IQ3_S (3.5 bits)", "download_gb": 58.4,
"ram_gb": 32, "arena_gb": 23.4, "families": ("coder",)},
}
CONTEXTS = [8192, 32768, 65536, 131072, 262144]
# The model families: the same architecture, weights in the same three GSQ-RCO sizes, different files.
@@ -80,11 +90,22 @@ FAMILIES = {
"mmproj_hf": "https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF/resolve/main/",
"mmproj": "mmproj-Swift-Qwen3.8-Flash-Next-BF16.gguf", "name": "swift-1.5",
"license": "Swift Open License 1.0: https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF"},
# ISTA-DASLab's expert-pruned release: half of each layer's experts removed, chosen for code, agentic tool use and
# vision; its shard 2 (the n-gram table) and vision encoder are the original's files, shared with it
"coder": {"title": "Qwen3.8-Flash-Next Coder", "by": "ISTA-DASLab's coding version",
"about": "half the experts (code, tools, images kept): needs ~32 GB of RAM, faster; weaker outside coding",
"hf": "https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF/resolve/main/{q}/",
"file": "Qwen3.8-Flash-Next-GSQ-RCO-{q}-0000{i}-of-00002.gguf", "tag": "coder-",
"mmproj_hf": "https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF/resolve/main/",
"mmproj": "mmproj-Qwen3.8-Flash-Next-BF16.gguf", "name": "qwen3.8-flash-next-coder",
"profile": "expert-profile-coder.bin"},
}
MMPROJ = "mmproj-Qwen3.8-Flash-Next-BF16.gguf"
# the image encoder on the GPU: ~0.9 GB of weights + ~0.3 GB of work buffers at 1024 image tokens, kept free of
# expert slots (the engine's default reserve is 700 MiB)
VISION = {"gpu": {"max_tokens": 1024, "reserve_mib": 2100},
# EXPERIMENTAL, off by default (setup asks): a control vector shipped with the repository, see its README
ESP_VECTOR = ROOT / "data" / "experimental-speed-projection" / "Qwen3.8-Flash-Next-experimental-speed-projection.gguf"
# the image encoder on the GPU (~1.2 GB at 1024 image tokens) warms up before the engine starts, so the engine
# sizes its expert slots around it and the default reserve (700 MiB) is enough; engines before 0.1.2 need more
VISION = {"gpu": {"max_tokens": 1024, "reserve_mib": 700},
"cpu": {"max_tokens": 300, "reserve_mib": 700}}
EXE = "strata.exe" if WIN else "strata"
VEXE = "strata-vision.exe" if WIN else "strata-vision"
@@ -240,6 +261,7 @@ def find_nvcc():
cands += [str(p / "bin" / "nvcc.exe") for p in sorted(base.iterdir(), reverse=True)]
else:
cands += [str(p / "bin" / "nvcc") for p in sorted(Path("/usr/local").glob("cuda*"), reverse=True)]
cands += [str(p / "bin" / "nvcc") for p in sorted(Path("/opt").glob("cuda*"), reverse=True)] # Arch (#46)
best = (None, None)
for c in dict.fromkeys(cands): # every toolkit found; the newest wins
if c and Path(c).exists():
@@ -456,6 +478,56 @@ def get_prebuilt(url_base, gpu, vision) -> Path | None:
return eng
def update_installed_engine(url_base) -> None:
"""An installed ready-made engine older than MIN_ENGINE is replaced before the model starts, so a plain
START-HERE.bat on an existing install picks up a new release. If that cannot happen (no internet, the model
still running, no ready-made engine for this GPU) the installed engine is kept and starts as before."""
eng = ROOT / "engine"
info = eng / "BUILD.json"
if not info.exists() or not (eng / EXE).exists():
return
meta_text = info.read_text()
meta = json.loads(meta_text)
ver = tuple(int(x) for x in str(meta.get("version", "0")).split(".")[:3] if x.isdigit())
local = meta.get("source") == "local"
vision = meta.get("vision") or "none"
if local: # compiled here: is it older than the source (a git pull)?
if meta.get("src") == source_hash(ENGINE_SOURCES) and \
(vision == "none" or meta.get("vision_src") == source_hash(VISION_SOURCES)):
return
elif ver >= MIN_ENGINE:
return
try: # a running engine cannot be replaced (Windows keeps it locked)
for x in (EXE, VEXE):
if (eng / x).exists():
with open(eng / x, "r+b"):
pass
except OSError:
warn(f"engine {meta.get('version') or ''} is in use: close the model window and run this again to update it")
return
gpu = gpu_info()
if local:
try: # a failed compile must not stop the model from starting
if gpu is None:
raise RuntimeError("no NVIDIA GPU found")
build_engine(gpu, vision, False, get_llama_cpp())
except (Exception, SystemExit) as e:
warn(f"could not compile the updated engine{'' if isinstance(e, SystemExit) else f' ({e})'}: starting the installed one")
return
new = None
if gpu is not None:
try:
new = get_prebuilt(url_base, gpu, "gpu")
except Exception as e: # a failed download must not stop the model from starting
warn(f"updating the engine failed ({e})")
if new is None:
if not info.exists():
info.write_text(meta_text) # get_prebuilt drops it before downloading: put it back
warn(f"could not update the engine: starting the installed {meta.get('version')}")
return
pip_install(CUDA_WHEELS, "NVIDIA CUDA libraries (cuBLAS, CUDA runtime; ~0.4 GB)")
def install_build_tools(gpu, yes):
"""The compiler and the CUDA toolkit, installed for the user (asks once). Returns (nvcc, vcvars)."""
nvcc, cuda_v = find_nvcc()
@@ -491,8 +563,9 @@ def install_build_tools(gpu, yes):
else:
apt = shutil.which("apt-get")
if apt is None:
fail("automatic install is only done on Ubuntu/Debian",
"install g++ and the CUDA Toolkit 13 (https://developer.nvidia.com/cuda-downloads), then run it again")
fail("missing: " + " and ".join(missing) + " (the automatic install is only done on Ubuntu/Debian)",
"install them with your distribution's packages (Arch: pacman -S base-devel cuda; nvcc is found on "
"PATH, in /usr/local/cuda* and in /opt/cuda*), then run it again")
if not have_cc:
run(["sudo", "apt-get", "install", "-y", "build-essential"])
if nvcc is None or cuda_v < need_cuda:
@@ -523,35 +596,61 @@ def cmake_build(src, bdir, target, defs, vcvars, bat_name):
conf = [cmake, "-G", "Ninja", f"-DCMAKE_MAKE_PROGRAM={ninja}", "-S", str(src), "-B", str(bdir),
"-DCMAKE_BUILD_TYPE=Release", *defs]
build = [cmake, "--build", str(bdir), "--target", target, "-j", str(max(2, (os.cpu_count() or 4) // 2))]
# A failed build is tried once more: CUDA 13.0's ptxas now and then fails to parse a PTX file it just wrote, and
# the same command then gets past it (issue #45); a second attempt only compiles what is still missing.
if WIN:
bat = ROOT / bat_name
q = lambda c: " ".join(f'"{x}"' if " " in str(x) else str(x) for x in c) # noqa: E731
bat.write_text(f'@echo off\r\ncall "{vcvars}" >nul\r\n{q(conf)} || exit /b 1\r\n{q(build)} || exit /b 1\r\n',
bat.write_text(f'@echo off\r\ncall "{vcvars}" >nul\r\n{q(conf)} || exit /b 1\r\n{q(build)} && exit /b 0\r\n'
f'echo (the build stopped - trying it once more)\r\n{q(build)} || exit /b 1\r\n',
encoding="utf-8")
run(["cmd", "/c", str(bat)])
else:
run(conf)
run(build)
if run(build, check=False).returncode != 0:
say(" (the build stopped - trying it once more)")
run(build)
ENGINE_SOURCES = ("CMakeLists.txt", "src", "include", "third_party/ggml")
VISION_SOURCES = ("tools/vision",)
def source_hash(parts) -> str:
"""A fingerprint of the files a compiled engine is built from, kept in engine/BUILD.json: when a `git pull`
changes them, the engine is compiled again (issue #31)."""
h = hashlib.sha256(LLAMA_CPP_COMMIT.encode())
for part in parts:
base = ROOT / part
for f in [base] if base.is_file() else sorted(x for x in base.rglob("*") if x.is_file()):
h.update(f.relative_to(ROOT).as_posix().encode() + b"\0" + f.read_bytes().replace(b"\r\n", b"\n"))
return h.hexdigest()[:16]
def build_engine(gpu, vision, yes, llama) -> Path:
"""Compile the engine (and, for images, the encoder) for this GPU; the results go to engine/."""
"""Compile the engine (and, for images, the encoder) for this GPU; the results go to engine/. A compiled
engine whose source files changed since (a `git pull`) is compiled again: only the changed files, a few minutes."""
eng = ROOT / "engine"
eng.mkdir(exist_ok=True)
stamp = eng / "BUILD.json"
meta = json.loads(stamp.read_text()) if stamp.exists() else {}
want_vision = vision != "none"
if meta.get("source") == "local" and (eng / EXE).exists() and (not want_vision or (eng / VEXE).exists()):
local = meta.get("source") == "local"
src, vsrc = source_hash(ENGINE_SOURCES), source_hash(VISION_SOURCES)
engine_ok = local and (eng / EXE).exists() and meta.get("src") == src
vision_ok = not want_vision or ((eng / VEXE).exists() and (not local or meta.get("vision_src") == vsrc))
if engine_ok and vision_ok:
ok("engine already built for this PC")
return eng
nvcc, vcvars = install_build_tools(gpu, yes)
if not (eng / EXE).exists() or meta.get("source") != "local":
say(" Compiling the Strata engine for your GPU (10-20 minutes, once) ...")
if not engine_ok:
say(" The engine's source changed: compiling it again (only what changed, a few minutes) ..."
if local and (eng / EXE).exists() else " Compiling the Strata engine for your GPU (10-20 minutes, once) ...")
cmake_build(ROOT, ROOT / "build", "strata",
["-DSTRATA_ENABLE_CUDA=ON", "-DSTRATA_BUILD_TESTS=OFF", f"-DCMAKE_CUDA_ARCHITECTURES={gpu['arch']}",
f"-DCMAKE_CUDA_COMPILER={nvcc}", f"-DSTRATA_GGML_DIR={llama}"], vcvars, "build-strata.bat")
shutil.copy2(ROOT / "build" / EXE, eng / EXE)
if want_vision and not (eng / VEXE).exists():
if not vision_ok:
say(" Compiling the image encoder" + (" with CUDA (10-20 minutes, once) ..." if vision == "gpu" else " ..."))
defs = [f"-DLLAMA_DIR={llama}", f"-DSTRATA_VISION_CUDA={'ON' if vision == 'gpu' else 'OFF'}"]
if vision == "gpu":
@@ -560,19 +659,270 @@ def build_engine(gpu, vision, yes, llama) -> Path:
shutil.copy2(ROOT / "build-vision" / "bin" / VEXE, eng / VEXE)
bindir = Path(nvcc).parent # the toolkit's own libraries (bin, bin/x64, lib64)
dirs = [str(d) for d in (bindir, bindir / "x64", bindir.parent / "lib64") if d.is_dir()]
stamp.write_text(json.dumps({"source": "local", "archs": [int(gpu["arch"])], "vision": vision,
"cuda_dirs": dirs}, indent=1))
stamp.write_text(json.dumps({"source": "local", "version": source_version(), "archs": [int(gpu["arch"])],
"vision": vision,
"cuda_dirs": dirs, "src": src, "vision_src": vsrc if want_vision else None}, indent=1))
ok(f"engine compiled: {eng / EXE}")
return eng
# ------------------------------------------------------------------------------------------------ the data folder
# The model files - the GGUFs, the prepared packs and the MTP layer, 70-120 GB - live in a data folder NEXT TO the
# Strata folder (`Strata-data`), not inside it: updating Strata by unzipping a new copy used to give a new, empty
# folder and a full download again. Where it is, and which Strata folders this user ran, is kept in a small
# per-user file, so every Strata folder on the PC finds the same files.
DATA_ITEMS = ("models", "packs", "mtp")
def settings_path() -> Path:
if WIN:
return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Strata" / "settings.json"
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "strata" / "settings.json"
def load_settings() -> dict:
try:
return json.loads(settings_path().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def save_settings(s: dict) -> None:
try:
settings_path().parent.mkdir(parents=True, exist_ok=True)
settings_path().write_text(json.dumps(s, indent=1), encoding="utf-8")
except OSError as e:
warn(f"could not save {settings_path()} ({e})")
def has_data(folder: Path) -> bool:
for d in DATA_ITEMS:
try:
if (folder / d).is_dir() and any((folder / d).iterdir()):
return True
except OSError:
pass
return False
def other_installs(settings: dict) -> list:
"""Strata folders besides this one that may hold model files: the ones this user ran before, and Strata* folders
next to this one (a zip unpacked again lands in e.g. `Strata-main (1)\\Strata-main`)."""
cands = [Path(p) for p in settings.get("installs", [])]
for base in dict.fromkeys((ROOT.parent, ROOT.parent.parent)):
try:
for d in base.iterdir():
if d.is_dir() and d.name.lower().startswith("strata"):
cands.append(d)
cands += [c for c in d.iterdir() if c.is_dir() and c.name.lower().startswith("strata")]
except OSError:
pass
found = []
for d in cands:
try:
d = d.resolve()
if d != ROOT and d not in found and (d / "setup.py").is_file():
found.append(d)
except OSError:
pass
return found
def same_drive(a: Path, b: Path) -> bool:
try:
return os.stat(a).st_dev == os.stat(b).st_dev
except OSError:
return False
def move_into(src: Path, dst: Path) -> None:
"""A rename into the data folder (same drive: instant); a folder merges into one already there, keeping what the
destination has. Whatever cannot be moved (a file in use) stays where it is."""
if not dst.exists():
try:
dst.parent.mkdir(parents=True, exist_ok=True)
os.replace(src, dst)
return
except OSError:
if not src.is_dir():
return
dst.mkdir(parents=True, exist_ok=True)
if src.is_dir() and dst.is_dir():
for c in list(src.iterdir()):
move_into(c, dst / c.name)
try:
src.rmdir()
except OSError:
pass
def repoint_config(cfg_file: Path, old: Path, new: Path) -> None:
"""A config whose model files moved from `old` to `new` points at them there (each path only if its file is
now there and no longer at the old place)."""
try:
cfg = json.loads(cfg_file.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return
def fix(v):
if isinstance(v, list):
return [fix(x) for x in v]
if isinstance(v, dict):
return {k: fix(x) for k, x in v.items()}
if isinstance(v, str):
for d in DATA_ITEMS:
o = str(old / d)
nv, no = os.path.normcase(v), os.path.normcase(o) # Windows: C:\ and c:\ are the same place
if nv == no or nv.startswith(no + os.sep):
n = str(new / d) + v[len(o):]
if Path(n).exists() and not Path(v).exists():
return n
return v
new_cfg = fix(cfg)
if new_cfg != cfg:
cfg_file.write_text(json.dumps(new_cfg, indent=1), encoding="utf-8")
def data_folder(requested: str | None) -> tuple:
"""(the data folder, folders on other drives that still hold model files). Moves the model files of this folder
and of earlier Strata folders on the same drive into the data folder, and points their configs there."""
settings = load_settings()
dest = Path(requested).expanduser().resolve() if requested else \
Path(settings["data_dir"]) if settings.get("data_dir") else ROOT.parent / "Strata-data"
try:
dest.mkdir(parents=True, exist_ok=True)
except OSError as e: # e.g. no write access next to the Strata folder
warn(f"cannot use {dest} for the model files ({e}): keeping them in {ROOT}")
dest = ROOT
elsewhere = []
for folder in [ROOT, *other_installs(settings)]:
if folder == dest or not has_data(folder):
continue
if not same_drive(folder, dest):
elsewhere.append(folder) # another drive: used where it is (no 70 GB copy)
continue
# the downloads merge file by file (the same file wherever it came from); a prepared pack or MTP layer moves
# whole or not at all, so two copies are never mixed
if (folder / "models").is_dir():
move_into(folder / "models", dest / "models")
for item in [*((folder / "packs").glob("*") if (folder / "packs").is_dir() else []), folder / "mtp"]:
rel = item.relative_to(folder)
if item.exists() and not (dest / rel).exists():
move_into(item, dest / rel)
for d in ("packs",):
try:
(folder / d).rmdir() # empty now
except OSError:
pass
for c in folder.glob("strata-*.json"):
repoint_config(c, folder, dest)
if has_data(folder):
elsewhere.append(folder) # in use, or a copy the data folder already has
warn(f"some model files are still in {folder} (in use, or already in {dest})")
else:
ok(f"model files from {folder} moved to {dest} (a new copy of Strata finds them there)")
installs = [str(ROOT)] + [p for p in settings.get("installs", []) if p != str(ROOT) and Path(p).is_dir()]
save_settings({**settings, "data_dir": str(dest), "installs": installs[:20]})
return dest, elsewhere
def previous_config(elsewhere_first: list, settings: dict):
"""The most recently used model config of another Strata folder on this PC, for a folder that has none yet."""
cands = []
for folder in [*elsewhere_first, *other_installs(settings)]:
cands += list(folder.glob("strata-*.json"))
cands = [c for c in dict.fromkeys(cands) if c.is_file()]
return max(cands, key=lambda p: p.stat().st_mtime) if cands else None
def choices_from_config(cfg_path: Path) -> dict:
"""The setup answers a config was written with (family, size, context, KV, images, projection, network)."""
cfg = json.loads(cfg_path.read_text(encoding="utf-8-sig"))
tag = cfg_path.stem[len("strata-"):]
family = next((f for f, d in FAMILIES.items() if d["tag"] and tag.startswith(d["tag"])), "qwen")
model = tag.split("-")[-1].upper()
a = cfg.get("args", [])
val = lambda k: a[a.index(k) + 1] if k in a and a.index(k) + 1 < len(a) else None # noqa: E731
vis = cfg.get("vision")
esp = val("--control-vector-scaled")
esp_path = esp.rsplit(":", 1)[0] if esp else None
return {"family": family, "model": model if model in MODELS else None,
"context": int(val("--max-context")) if val("--max-context") else None,
"kv": val("--kv") if val("--kv") in ("int8", "q4_0") else None,
"vision": ("gpu" if vis.get("gpu") else "cpu") if isinstance(vis, dict) else "none",
"esp": ("on" if Path(esp_path).name == ESP_VECTOR.name else esp_path) if esp_path else "off",
"host": cfg.get("host"), "api_key": cfg.get("api_key"), "port": cfg.get("port")}
def find_in(roots: list, rel: str):
"""The first of roots/rel that exists."""
for r in roots:
if (r / rel).exists():
return r / rel
return None
# ------------------------------------------------------------------------------------------------ start
def installed_configs():
return sorted(ROOT.glob("strata-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
def source_version() -> str:
"""The engine version the source tree builds (CMakeLists.txt's project version)."""
m = re.search(r"project\(strata VERSION ([\d.]+)", (ROOT / "CMakeLists.txt").read_text(encoding="utf-8"))
return m.group(1) if m else "0"
def engine_version(exe: Path) -> tuple:
"""The version in the engine folder's BUILD.json, or else the one compiled into the binary. A locally compiled
engine is not necessarily the source's version: when compiling a `git pull` fails, the previous engine is kept
(issue #49)."""
try:
meta = json.loads((Path(exe).parent / "BUILD.json").read_text())
except (OSError, ValueError):
meta = {}
v = str(meta.get("version") or "")
if not v: # the version compiled into the binary: 0.1.13 and newer
try: # carry it, so a binary without it is older
m = re.search(rb"engine=(\d+\.\d+\.\d+)\n", Path(exe).read_bytes())
v = m.group(1).decode() if m else "0.1.12"
except OSError:
v = "0"
return tuple(int(x) for x in v.split(".")[:3] if x.isdigit())
def is_wsl() -> bool:
return sys.platform.startswith("linux") and "microsoft" in platform.uname().release.lower()
def upgrade_config(cfg_path: Path, cfg: dict) -> dict:
"""Configs written before v0.1.13 read prompts in fixed 2048-token chunks; the engine now picks the chunk
itself (`--prefill auto`: up to 8192, as the free VRAM allows - about 2x faster on long prompts). Under WSL,
KV streaming is dropped: its RAM copy must be pinned, and the driver pins only about 1 GB there."""
a = cfg.get("args", [])
changed = False
ver = engine_version(cfg["exe"]) if "--prefill" in a else (0, 0, 0)
if "--prefill" in a and a[a.index("--prefill") + 1] == "2048" and ver >= (0, 1, 13):
a[a.index("--prefill") + 1] = "auto"
changed = True
ok("prompt reading: the engine now picks its chunk size (--prefill auto)")
elif "--prefill" in a and a[a.index("--prefill") + 1] == "auto" and (0, 0, 0) < ver < (0, 1, 13):
a[a.index("--prefill") + 1] = "2048" # an older engine kept after a failed update (issue #49)
changed = True
warn(f"the installed engine is {'.'.join(map(str, ver))}: prompts are read in 2048-token chunks until it is updated")
if is_wsl() and "--kv-resident" in a:
i = a.index("--kv-resident")
del a[i:i + 2]
changed = True
ok("WSL: KV streaming off (the driver pins only about 1 GB of RAM); the KV cache stays in VRAM")
if changed:
cfg_path.write_text(json.dumps(cfg, indent=1), encoding="utf-8")
return cfg
def start(cfg_path: Path, port: int | None, open_browser=True) -> int:
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
cfg = upgrade_config(cfg_path, json.loads(cfg_path.read_text(encoding="utf-8-sig")))
missing = [p for p in [cfg["exe"], *[a for a in cfg["args"] if a.endswith(".gguf")]] if not Path(p).exists()]
if missing:
fail(f"{cfg_path.name} refers to missing files: {missing[0]}", "run it again with --setup to repair")
@@ -581,8 +931,20 @@ def start(cfg_path: Path, port: int | None, open_browser=True) -> int:
"--port", str(port or cfg.get("port", 8080))]
if open_browser:
cmd.append("--open")
gb = 0.0
if "--native" in cfg["args"]:
try:
gb = Path(cfg["args"][cfg["args"].index("--native") + 1]).stat().st_size / 1e9
except (OSError, IndexError):
pass
say()
say(f"Starting {cfg.get('model_name', 'the model')} (loads 34-43 GB into RAM: 30-90 s). Close this window to stop it.")
say(" " + "-" * 100)
say(f" Starting {cfg.get('model_name', 'the model')}: it loads {f'about {gb:.0f} GB' if gb >= 1 else '34-55 GB'} "
"into RAM and locks part of it for the GPU.")
say(" While it does, YOUR PC CAN BE SLOW OR STOP RESPONDING FOR 1-3 MINUTES (longer the first time after a")
say(" restart). That is normal: please wait and don't close this window - the browser opens when it is ready.")
say(" Later, closing this window stops the model.")
say(" " + "-" * 100)
return subprocess.call(cmd)
@@ -607,10 +969,21 @@ def main() -> int:
ap.add_argument("--family", choices=list(FAMILIES), help="qwen = Qwen3.8-Flash-Next, swift = Swift 1.5")
ap.add_argument("--model", choices=list(MODELS))
ap.add_argument("--context", type=int)
ap.add_argument("--kv", choices=["int8", "q4_0"],
help="KV cache precision above 8K context: int8 (default) or q4_0 (half the memory, a little less "
"precise)")
ap.add_argument("--vision", choices=["yes", "no", "none", "gpu", "cpu"],
help="let the model read images (yes = the encoder on the GPU)")
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--models-dir", default=str(ROOT / "models"), help="where the GGUF files go (~70 GB)")
ap.add_argument("--experimental-speed-projection", metavar="on|off|GGUF",
help="EXPERIMENTAL, off by default: the control vector in data/experimental-speed-projection "
"(or another GGUF) as a projection on layers 4-44; see docs/DETAILS.md")
ap.add_argument("--port", type=int, help="the server's port (default: the one the install was set up with, 8080 for a new one)")
ap.add_argument("--host", help="where the server listens: 127.0.0.1 = this PC only (default), 0.0.0.0 = also other "
"devices on your network (issue #26; set --api-key too)")
ap.add_argument("--api-key", help="require this key from clients (recommended with --host 0.0.0.0)")
ap.add_argument("--data-dir", help="where the model files go (~70-120 GB): default Strata-data next to this folder, "
"remembered for every Strata folder on this PC")
ap.add_argument("--models-dir", help="where the GGUF files go (default: <data folder>/models)")
ap.add_argument("--gguf-dir", help="use GGUF files you already have (a folder with the two shards)")
ap.add_argument("--yes", action="store_true", help="accept the recommended answers")
ap.add_argument("--setup", action="store_true", help="install another model or change settings")
@@ -621,21 +994,42 @@ def main() -> int:
ap.add_argument("--check", action="store_true", help="only check this PC and exit")
ap.add_argument("--skip-build", action="store_true", help=argparse.SUPPRESS)
a = ap.parse_args()
say("Strata - Qwen3.8-Flash-Next on a normal PC (NVIDIA GPU + system RAM + CPU)")
data, elsewhere = data_folder(a.data_dir) # the model files: in the data folder, found from any copy
roots = [data, *elsewhere]
if a.models_dir is None:
a.models_dir = str(data / "models")
# ---- 0. already installed: just start it
have = installed_configs()
explicit = a.setup or a.model or a.family or a.check or a.no_start
if not have and not explicit: # a new copy of Strata (an update unzipped elsewhere): set it
prev = previous_config(elsewhere, load_settings()) # up like the last one, from the files already here
if prev is not None:
ch = choices_from_config(prev)
if ch["model"]:
say(f" Found your earlier install in {prev.parent} ({prev.stem[len('strata-'):]}): setting up this "
"copy the same way - the model files are reused, nothing big is downloaded.")
a.family, a.model, a.context = ch["family"], ch["model"], a.context or ch["context"]
a.kv = a.kv or ch["kv"]
a.vision = a.vision or ch["vision"]
a.experimental_speed_projection = a.experimental_speed_projection or ch["esp"]
a.host, a.api_key = a.host or ch["host"], a.api_key or ch["api_key"]
a.port = a.port or ch["port"]
a.yes = True
port = a.port or 8080 # a new install's port (issue #32: --port for an existing one)
if have and not (a.setup or a.model or a.family or a.check or a.no_start):
if not a.build:
update_installed_engine(a.prebuilt)
if len(have) == 1:
return start(have[0], None)
return start(have[0], a.port)
say()
for i, c in enumerate(have, 1):
say(f" {i}) {json.loads(c.read_text(encoding='utf-8')).get('model_name', c.stem)}")
say(f" {i}) {json.loads(c.read_text(encoding='utf-8-sig')).get('model_name', c.stem)}")
say(f" {len(have) + 1}) install another model / change settings")
pick = int(ask("Which one?", [str(i) for i in range(1, len(have) + 2)], "1", a.yes))
if pick <= len(have):
return start(have[pick - 1], None)
return start(have[pick - 1], a.port)
# ---- 1. the PC
step(1, "checking your PC")
@@ -654,7 +1048,14 @@ def main() -> int:
warn("less than 12 GB of VRAM: Strata will run, but most experts stay on the CPU and it will be slow")
ram = ram_gb()
cpu, avx2, avx512 = cpu_info()
ok(f"RAM: {ram:.0f} GB")
need = min(d["ram_gb"] for d in MODELS.values())
if ram < need - 4 and not a.check:
# every model keeps ALL its experts in RAM (23+ GB); VRAM only holds a copy of the most-used ones, so a
# bigger GPU does not lower this
fail(f"RAM: {ram:.0f} GB - the smallest model (the Coder) needs about {need} GB",
"Strata keeps all of the model's experts in RAM (23-50 GB, whatever the GPU) and the GPU holds a copy "
"of the most-used ones: it needs 32 GB of RAM or more (48 GB for the full model)")
ok(f"RAM: {ram:.0f} GB" if ram >= need - 4 else f"RAM: {ram:.0f} GB (less than the {need} GB the smallest model needs)")
ok(f"CPU: {cpu} ({'AVX-512' if avx512 else 'AVX2' if avx2 else 'no AVX2'})")
if not avx2:
fail("this CPU has no AVX2; Strata needs at least AVX2")
@@ -681,13 +1082,15 @@ def main() -> int:
if fam.get("license"):
say(f" Its license: {fam['license']}")
say()
names = list(MODELS)
names = [m for m in MODELS if family in MODELS[m].get("families", ("qwen", "swift"))]
if a.model and a.model not in names:
fail(f"{fam['title']} has no {a.model} model file", "choose one of: " + ", ".join(names))
for i, m in enumerate(names, 1):
d = MODELS[m]
fit = "" if ram >= d["ram_gb"] else f" <- needs {d['ram_gb']} GB RAM, you have {ram:.0f}"
say(f" {i}) {m:8s} {d['about']}; download {d['download_gb']:.0f} GB, uses ~{d['arena_gb']:.0f} GB of RAM{fit}")
rec = "3" if ram >= 60 else "1"
model = a.model or names[int(ask("Which size?", ["1", "2", "3"], rec, a.yes)) - 1]
rec = str(names.index("IQ3_XXS") + 1) if ram >= 60 and "IQ3_XXS" in names else "1"
model = a.model or names[int(ask("Which size?", [str(i) for i in range(1, len(names) + 1)], rec, a.yes)) - 1]
if ram < MODELS[model]["ram_gb"] - 4:
fail(f"{model} needs about {MODELS[model]['ram_gb']} GB of RAM; this PC has {ram:.0f} GB",
"choose Q2_0 or IQ2_XS, or add RAM")
@@ -703,10 +1106,22 @@ def main() -> int:
for i, c in enumerate(CONTEXTS, 1):
say(f" {i}) {c // 1024}K tokens" + (" (recommended for your GPU)" if c == rec_ctx else ""))
ctx = CONTEXTS[int(ask("Context?", [str(i) for i in range(1, 6)], str(CONTEXTS.index(rec_ctx) + 1), a.yes)) - 1]
if model == "IQ3_XXS" and ram < 90 and ctx > 131072:
warn("IQ3_XXS with a 262K context needs more than 64 GB of RAM (43 GB of experts + the context): using 128K")
if model in ("IQ3_XXS", "IQ3_S") and ram < 90 and ctx > 131072:
warn(f"{model} with a 262K context needs more than 64 GB of RAM ({MODELS[model]['arena_gb']:.0f} GB of experts "
"+ the context): using 128K")
ctx = 131072
ok(f"context: {ctx} tokens")
# the KV cache (the model's memory of the conversation): 8-bit, or 4-bit after a Hadamard rotation (PR #21)
kv = "fp16" if ctx <= 8192 else (a.kv or "int8")
if ctx > 8192 and not a.kv and not a.yes:
say()
say(" KV cache precision (the model's memory of the conversation):")
say(" 1) 8-bit (recommended: what every published number was measured with)")
say(" 2) 4-bit half the memory (about 4% faster at 128K), but measurably less precise on long")
say(" documents; long-context lookups (needle tests) still pass")
kv = ["int8", "q4_0"][int(ask("KV cache?", ["1", "2"], "1", a.yes)) - 1]
if ctx > 8192:
ok(f"KV cache: {'8-bit' if kv == 'int8' else '4-bit (Hadamard-rotated)'}")
if a.vision:
vision = {"yes": "gpu", "no": "none"}.get(a.vision, a.vision)
else:
@@ -715,8 +1130,34 @@ def main() -> int:
say(" download and keeps ~1.4 GB of VRAM free for the image encoder, so text is a few % slower.")
vision = "gpu" if ask("Do you want images?", ["y", "n"], "n", a.yes) == "y" else "none"
ok("images: " + {"none": "off", "gpu": "on", "cpu": "on (encoder on the CPU)"}[vision])
# EXPERIMENTAL: the experimental-speed-projection control vector (data/experimental-speed-projection), off unless
# chosen here; with it loaded, the web app and the API switch it off per request
esp = None
esp_choice = (a.experimental_speed_projection or "").strip()
if family in ("qwen", "coder"): # the Coder: the same model's residual stream
if not esp_choice:
say()
say(" EXPERIMENTAL - speed projection: a small control vector applied while the model runs (layers 4-44).")
say(" It changes how the model answers: its package describes it as a refusal-direction projection (the")
say(" model declines far fewer requests). Off unless you choose it; when on, the web app can switch it off")
say(" per chat. Details: data/experimental-speed-projection/README.md")
esp_choice = "on" if ask("Turn on the experimental speed projection?", ["y", "n"], "n", a.yes) == "y" else "off"
if esp_choice.lower() not in ("off", "no", "n", "0"):
esp = ESP_VECTOR if esp_choice.lower() in ("on", "yes", "y", "1") else Path(esp_choice).expanduser().resolve()
if not esp.is_file():
fail(f"the experimental speed projection's vector is missing: {esp}")
ok("experimental speed projection: " + ("ON (experimental)" if esp else "off"))
elif esp_choice.lower() not in ("", "off", "no", "n", "0"):
warn("the experimental speed projection is made for the original Qwen3.8-Flash-Next, not Swift 1.5: left off")
models_dir = Path(a.gguf_dir) if a.gguf_dir else Path(a.models_dir) / tag
shards = [models_dir / fam["file"].format(q=model, i=i) for i in (1, 2)]
if not a.gguf_dir and not all(sh.exists() and done(sh) for sh in shards):
for r in elsewhere: # already downloaded in a Strata folder on another drive
cand = [r / "models" / tag / sh.name for sh in shards]
if all(c.exists() and done(c) for c in cand):
models_dir, shards = cand[0].parent, cand
ok(f"model files found in {models_dir}")
break
have_model = all(s.exists() and (done(s) or a.gguf_dir) for s in shards)
need = (0 if a.gguf_dir or have_model else MODELS[model]["download_gb"]) + 8 + \
(40 if model == "Q2_0" and avx512 and family == "qwen" else 0) + (1 if vision != "none" else 0)
@@ -725,7 +1166,7 @@ def main() -> int:
# ---- 3. python packages
step(3, "Python packages")
pip_install(PY_PACKAGES, "numpy, jinja2, regex, pyyaml, tqdm, requests, cmake, ninja")
pip_install(PY_PACKAGES, "numpy, jinja2, regex, pyyaml, tqdm, requests, cmake, ninja, pillow, psutil")
# ---- 4. the engine
step(4, "the Strata engine")
@@ -750,9 +1191,9 @@ def main() -> int:
if s.exists() and done(s):
ok(f"{s.name} already downloaded")
continue
# the original's shard 2 is the same file for all three sizes: reuse one that is already here
# the original's shard 2 is the same file for all its sizes and the Coder: reuse one that is already here
other = [p for p in Path(a.models_dir).glob("*/Qwen3.8-Flash-Next-GSQ-RCO-*-00002-of-00002.gguf") if done(p)]
if family == "qwen" and s.name.endswith("00002-of-00002.gguf") and other and not s.exists():
if family in ("qwen", "coder") and s.name.endswith("00002-of-00002.gguf") and other and not s.exists():
try:
os.link(other[0], s)
mark(s)
@@ -766,6 +1207,8 @@ def main() -> int:
fail(f"missing {s}")
ok("model files present")
mmproj = Path(a.models_dir) / fam["mmproj"]
if not mmproj.exists():
mmproj = find_in(roots, f"models/{fam['mmproj']}") or mmproj
if vision != "none":
if not mmproj.exists() and a.gguf_dir and (Path(a.gguf_dir) / fam["mmproj"]).exists():
mmproj = Path(a.gguf_dir) / fam["mmproj"]
@@ -775,7 +1218,7 @@ def main() -> int:
# ---- 6. the pack and the MTP draft layer
step(6, "preparing the model for Strata")
pack = ROOT / "packs" / tag.lower()
pack = find_in(roots, f"packs/{tag.lower()}") or data / "packs" / tag.lower()
env = dict(os.environ, STRATA_GGUF_PY=str(llama / "gguf-py"))
if model == "Q2_0" and avx512 and family == "qwen":
# the Q2_0 experts repacked for the AVX-512 kernel (the measured speed): a one-time ~40 GB conversion
@@ -791,7 +1234,7 @@ def main() -> int:
# every tensor as the GGUF stores it; the experts are read from the GGUF at start (seconds to build)
run([sys.executable, str(ROOT / "tools" / "iq_pack.py"), "--gguf", str(shards[0]), "--out", str(pack)], env=env)
ok(f"model prepared: {pack}")
mtp = ROOT / "mtp"
mtp = (find_in(roots, "mtp/rt/experts.bin") or data / "mtp/rt/experts.bin").parent.parent
rt = mtp / "rt"
if not (rt / "experts.bin").exists():
say(" The MTP draft layer (speculative decoding, ~2x faster output) comes from the original Qwen checkpoint:")
@@ -813,16 +1256,34 @@ def main() -> int:
if ple is None:
fail("the model has no per_layer_token_embd tensor (is this a Qwen3.8-Flash-Next GGUF?)")
args = ["--pack", str(pack), "--native", str(shards[0]), "--ple-gguf", str(ple),
"--expert-profile", str(ROOT / "data" / "expert-profile.bin"), "--expert-cache", "auto",
"--prefill", "2048", "--spec", "4", "--spec-min-p", "0.5", "--mtp", str(rt),
"--expert-profile", str(ROOT / "data" / fam.get("profile", "expert-profile.bin")), "--expert-cache", "auto",
"--prefill", "auto", "--spec", "4", "--spec-min-p", "0.5", "--mtp", str(rt),
"--max-context", str(ctx)]
if ctx > 8192:
args += ["--kv", "int8"]
args += ["--kv", kv]
# KV streaming: from 64K up the whole KV cache lives in RAM and only the part the attention reads (32K positions
# per layer) stays in VRAM; the VRAM it frees holds more experts (+6% at 128K, +23% at 262K with Q2_0). It
# costs ~13.7 KB of RAM per context token with 8-bit KV (1.7 GB at 128K), 7.5 KB with 4-bit, so only when it fits.
kv_ram_gb = ctx * (13 * (576 if kv == "q4_0" else 1056)) / 1e9 # 12 QSA layers + the draft layer
# the RAM copy must be pinned, and under WSL the NVIDIA driver pins only about 1 GB in all
if is_wsl() and ctx >= 65536:
ok("WSL: KV streaming off (the driver pins only about 1 GB of RAM); the KV cache stays in VRAM")
elif ctx >= 65536 and ram >= MODELS[model]["ram_gb"] + kv_ram_gb + 1:
args += ["--kv-resident", "32768"]
ok(f"KV streaming on: the context's KV cache lives in RAM ({kv_ram_gb:.1f} GB), more experts fit in VRAM")
if vision != "none":
args += ["--vision", "--vram-reserve-mib", str(VISION[vision]["reserve_mib"])]
if esp is not None:
# the package's profile, with llama.cpp's flags (the engine takes the same ones)
args += ["--control-vector-scaled", f"{esp}:1.0", "--control-vector-layer-range", "4", "44",
"--cvec-mode", "project", "--cvec-dir", "per-layer"]
cfg = {"exe": str(eng / EXE), "args": args, "cwd": str(ROOT), "tokenizer": str(pack / "tokenizer"),
"model_name": f"{fam['name']}-{model.lower()}", "log": str(ROOT / f"strata-{tag.lower()}.log"),
"lib_dirs": lib_dirs, "port": a.port}
"lib_dirs": lib_dirs, "port": port}
if a.host:
cfg["host"] = a.host
if a.api_key:
cfg["api_key"] = a.api_key
if vision != "none":
cfg["vision"] = {"exe": str(eng / VEXE), "mmproj": str(mmproj), "model": str(shards[0]),
"gpu": vision == "gpu", "max_tokens": VISION[vision]["max_tokens"]}
@@ -830,19 +1291,22 @@ def main() -> int:
cfg["vision"]["threads"] = max(1, (os.cpu_count() or 8) // 2)
cfg_path = ROOT / f"strata-{tag.lower()}.json"
cfg_path.write_text(json.dumps(cfg, indent=1), encoding="utf-8")
script = write_run_script(tag, cfg_path, a.port)
script = write_run_script(tag, cfg_path, port)
ok(f"start script: {script.name}")
say()
say("All set.")
say(f" API (OpenAI): http://127.0.0.1:{a.port}/v1 (any API key; model name: anything)")
say(f" API (Anthropic): http://127.0.0.1:{a.port}/v1/messages")
say(f" API (OpenAI): http://127.0.0.1:{port}/v1 (any API key; model name: anything)")
say(f" API (Anthropic): http://127.0.0.1:{port}/v1/messages")
if a.host and a.host not in ("127.0.0.1", "localhost"):
say(f" Other devices: the server window prints this PC's address (http://<IP>:{port}/)"
+ ("" if a.api_key else " - no API key set: anyone on your network can use it"))
say(f" Next time: just run {'START-HERE.bat' if WIN else './setup.sh'} (or {script.name}) - it starts right away")
if vision != "none":
say(" Images: send them in the chat page, in chat.py (/image <path>) or over the API")
if a.no_start:
return 0
return start(cfg_path, a.port)
return start(cfg_path, port)
if __name__ == "__main__":
Regular → Executable
+12 -4
View File
@@ -2,10 +2,17 @@
# Strata for Linux: the first run installs everything and starts the model; later runs just start it.
# Needs only an NVIDIA driver. Python (with venv) is installed through apt/dnf if it is missing (asks for sudo).
cd "$(dirname "$0")" || exit 1
# Python 3.10+ that can make a venv WITH pip: Debian/Ubuntu ship `venv` without `ensurepip` (that is the separate
# python3-venv package), and a venv made without it has no pip
ok_py() { "$1" -c 'import sys, venv, ensurepip; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; }
# a .venv from an earlier run that failed half-way has a python but no pip: start it again
if [ -x .venv/bin/python ] && ! .venv/bin/python -m pip --version >/dev/null 2>&1; then
rm -rf .venv
fi
if [ ! -x .venv/bin/python ]; then
PY=""
for c in python3 python; do
if command -v $c >/dev/null 2>&1 && $c -c 'import sys, venv; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then
if command -v $c >/dev/null 2>&1 && ok_py $c; then
PY=$c; break
fi
done
@@ -19,12 +26,13 @@ if [ ! -x .venv/bin/python ]; then
sudo pacman -S --noconfirm python python-pip
fi
PY=python3
if ! $PY -c 'import sys, venv; sys.exit(0 if sys.version_info >= (3, 10) else 1)' 2>/dev/null; then
echo "Please install Python 3.10 or newer (with venv), then run ./setup.sh again."
if ! ok_py $PY; then
echo "Please install Python 3.10 or newer with venv (Ubuntu/Debian: sudo apt install python3-venv), then run"
echo "./setup.sh again."
exit 1
fi
fi
# a private environment inside this folder (system Python stays untouched; newer distros refuse global pip)
$PY -m venv .venv || { echo "could not create .venv: sudo apt install python3-venv"; exit 1; }
$PY -m venv .venv || { rm -rf .venv; echo "could not create .venv: sudo apt install python3-venv"; exit 1; }
fi
exec .venv/bin/python setup.py "$@"
+11
View File
@@ -13,6 +13,7 @@
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <thread>
@@ -263,8 +264,14 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
if (native && d.nact_multi.size() < (size_t) MAXT * kNativeActBytes) d.nact_multi.resize((size_t) MAXT * kNativeActBytes);
if (d.job_of.size() != (size_t) d.n_expert) d.job_of.assign((size_t) d.n_expert, (int16_t) -1);
if (d.jobs_multi.size() < (size_t) (n_tok * k)) d.jobs_multi.resize((size_t) (MAXT * k));
static const bool ptrace = std::getenv("STRATA_POOL_TRACE") != nullptr;
auto pt = [&](const char* what, long long a = -1) {
if (ptrace) { std::fprintf(stderr, "pool trace: layer %lld %s %lld\n", (long long) d.layers, what, a); std::fflush(stderr); }
};
const auto c0 = std::chrono::steady_clock::now();
pt("begin");
d.src->begin_layer(d.layers, ids, n_tok * k);
pt("begun");
if (!d.usage.empty())
for (int64_t i = 0; i < n_tok * k; ++i)
if (ids[i] >= 0 && ids[i] < d.n_expert) d.usage[(size_t) d.layers * (size_t) d.n_expert + (size_t) ids[i]] += 1.0f;
@@ -348,7 +355,9 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
P.counts[1] = entries;
P.counts[2] = fetches;
std::atomic_thread_fence(std::memory_order_seq_cst);
pt("publish", fetches);
if (P.publish) P.publish(P.ctx);
pt("fetch", fetches);
if (P.fetch) P.fetch(P.ctx, dma_src, P.pcie_mode != 0 ? 0 : fetches, (size_t) bb); // the copy engine, beside the CPU's work
} else {
for (int64_t i = 0; i < n; ++i) {
@@ -409,9 +418,11 @@ void expert_pool_dispatch_multi(ExpertDispatch& d, const float* x_f, const int32
++d.multi_entries;
}
const auto c3 = std::chrono::steady_clock::now();
pt("run", njobs);
if (native) d.pool->run_split_multi_native(lay.fmt[(size_t) d.layers], d.jobs_multi.data(), njobs);
else d.pool->run_split_multi(d.jobs_multi.data(), njobs);
const auto c4 = std::chrono::steady_clock::now();
pt("ran");
auto ms = [](auto a, auto b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
d.ms_plan += ms(c0, c1);
d.ms_actq += ms(c1, c2);
+256 -49
View File
@@ -7,6 +7,7 @@
#include "strata/kernels/elementwise.hpp"
#include "strata/kernels/gdn.hpp"
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/kv_q8.hpp"
#include "strata/kernels/quantize_act.hpp"
#include "strata/kernels/rope.hpp"
@@ -24,9 +25,12 @@
#include "strata/kernels/native_rope.hpp"
#include "strata/kernels/native_flash_attn.hpp"
#include "strata/kernels/fused_gr.hpp"
#include "strata/kernels/cvec.hpp"
#include "strata/kernels/qsa_decode_attn.hpp"
#include "strata/kernels/fused_gdn.hpp"
#include "strata/kernels/qsa_select.hpp"
#include <algorithm>
#include <cstdlib>
#include <exception>
#include <cuda_runtime.h>
#include <cmath>
@@ -168,9 +172,11 @@ if (!w.wants_q8k()) { s_gemv_q8_0_split(x80, p.codes, p.scales, p.offset,
// namespace
void layer_set_native_bf16(bool enabled) { native_bf16_projections = enabled; }
void layer_set_native_flash_attn_short(bool enabled) { native_flash_attn_short = enabled; }
namespace { bool g_kv_int8 = false; }
namespace { bool g_kv_int8 = false, g_kv_q4 = false; }
void qsa_set_kv_int8(bool enabled) { g_kv_int8 = enabled; }
bool qsa_kv_int8() { return g_kv_int8; }
void qsa_set_kv_q4(bool enabled) { g_kv_q4 = enabled; }
bool qsa_kv_q4() { return g_kv_q4; }
uint64_t gdn_buffers_bytes(const ModelGeometry& g) { const int64_t C = g.ssm_conv_channels; const int64_t V = g.ssm_value_dim; const uint64_t parts[] = { q8k_bytes(g.n_embd),
// x_q8k
(uint64_t) (g.n_embd / 32) * 34,
@@ -356,11 +362,8 @@ if (!native_bf16_projections) f32_to_bf16_bulk(x, b.x_bf16, g.n_embd, stream);
// LEDGER L41 -> L42.
project_bf16(x, b.x_bf16, (const uint16_t*) w_router->data, b.logits, g.n_embd, g.n_expert, true, stream);
// ---- routing: softmax over ALL experts, stable descending argsort with ties by index, gather, renormalise
if (native_router_enabled()) {
if (g.n_expert != 512 || k != 10) {
err = v.name("router") + ": native router requires 512 experts and k=10";
return false;
}
// the native fused router is canonical-512x10 only; anything else takes the generic top-k kernel
if (native_router_enabled() && g.n_expert == 512 && k == 10) {
try { native_router_top10(b.logits, b.ids, b.weights, stream); }
catch (const std::exception& error) { err = v.name("router") + ": " + error.what(); return false; }
} else router_top10(b.logits, 1, (int) g.n_expert, (int) k, b.ids, b.weights, stream);
@@ -475,39 +478,223 @@ struct Cursor { uint8_t* p; uint64_t used = 0; template <typename T>
// dispatch too and the two must not be able to drift.
}
// namespace
uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_rope) { const QsaShapes s = qsa_shapes(g); const int64_t pages = (max_cells + s.page_size - 1) / s.page_size; uint64_t n = 0; n += g_kv_int8 ? (uint64_t) pages * s.page_size * strata::kernels::kv_q8_bytes_per_cell(s) + 64 : (uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim * 2 * 2;
// k_pool + v_pool, fp16
n += (uint64_t) pages * 4;
// page_table
n += (uint64_t) (s.idx_block - 1) * s.idx_dim * 4;
// tail
n += (uint64_t) s.idx_dim * 4;
// dead
n += (uint64_t) ((max_cells / s.idx_block) + 2) * s.idx_dim * 4;
// pooled
n += 16;
// block_pos
if (with_rope) n += (uint64_t) max_cells * (s.n_rot / 2) * 4 * 2;
// cos + sin tables
n += strata::kernels::qsa_step_bytes() + 16; // counts and aligned attention status
// the per-token counts
n += (uint64_t) s.n_head * 4;
// pos_dev
return align_up16(n) + 256;}
uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st, const QsaState* share_rope) { const QsaShapes s = qsa_shapes(g); const int64_t pages = (max_cells + s.page_size - 1) / s.page_size; Cursor c{(uint8_t*) base}; st.kv_int8 = g_kv_int8; if (g_kv_int8) { const uint64_t cells = (uint64_t) pages * s.n_head_kv * s.page_size; st.k_q = c.take<int8_t>(cells * s.head_dim); st.v_q = c.take<int8_t>(cells * s.head_dim); st.k_scale = c.take<uint16_t>(cells * (s.head_dim / strata::kernels::KV_Q8_GROUP)); st.v_scale = c.take<uint16_t>(cells * (s.head_dim / strata::kernels::KV_Q8_GROUP)); } else { st.k_pool = c.take<uint16_t>((uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim); st.v_pool = c.take<uint16_t>((uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim); } st.page_table = c.take<int32_t>((uint64_t) pages); st.n_pages = pages; st.max_cells = max_cells; st.idx_tail = c.take<float>((uint64_t) (s.idx_block - 1) * s.idx_dim); st.idx_dead = c.take<float>((uint64_t) s.idx_dim); st.idx_pooled = c.take<float>((uint64_t) ((max_cells / s.idx_block) + 2) * s.idx_dim); st.idx_block_pos = c.take<int32_t>(1); if (share_rope != nullptr) { st.cos_tab = share_rope->cos_tab; st.sin_tab = share_rope->sin_tab; } else { st.cos_tab = c.take<float>((uint64_t) max_cells * (s.n_rot / 2)); st.sin_tab = c.take<float>((uint64_t) max_cells * (s.n_rot / 2)); } st.step = c.take<int32_t>((uint64_t) strata::kernels::kStepCount); st.attention_status = c.take<int32_t>(1); st.pos_dev = c.take<int32_t>((uint64_t) s.n_head);
// the pinned staging the uploads copy FROM - see the note on `host_step` in the header
if (cudaHostAlloc((void**) &st.host_step, strata::kernels::qsa_step_bytes() + sizeof(int32_t), cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess || cudaHostAlloc((void**) &st.host_pos, (size_t) s.n_head * 4, cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess) { return 0;
// the caller sees a zero byte count; a half-built state is worse than none
namespace {
// KV streaming (docs/kv-streaming-design.md): 0 keeps every cell in VRAM.
int64_t g_kv_resident = 0;
uint64_t g_kv_host_bytes = 0;
/// How one state holds its K/V: `mode` as in QsaState::kv_mode, `slots` VRAM pages of `pages` logical ones.
struct KvPlan {
int mode = 0;
int64_t pages = 0, slots = 0, pooled_rows = 0;
};
KvPlan kv_plan(const QsaShapes& s, int64_t max_cells, int64_t ring_cells) {
KvPlan p;
p.pages = (max_cells + s.page_size - 1) / s.page_size;
p.slots = p.pages;
p.pooled_rows = max_cells / s.idx_block + 2;
if (g_kv_resident <= 0 || ring_cells < 0) return p; // ring_cells < 0: always fully resident
// A/B only: STRATA_KV_RING_OFF keeps the drafter fully resident, STRATA_KV_MAIN_OFF the main layers
static const bool ring_off = std::getenv("STRATA_KV_RING_OFF") != nullptr;
static const bool main_off = std::getenv("STRATA_KV_MAIN_OFF") != nullptr;
if (ring_cells > 0 ? ring_off : main_off) return p;
if (ring_cells > 0) {
const int64_t r = (ring_cells + s.page_size - 1) / s.page_size;
if (r < p.pages) { p.mode = 2; p.slots = r; p.pooled_rows = 2; } // the drafter has no indexer
} else {
const int64_t r = (std::max(g_kv_resident, qsa_kv_resident_min()) + s.page_size - 1) / s.page_size;
if (r < p.pages) { p.mode = 1; p.slots = r; }
}
return p;
}
st.host_step[strata::kernels::kStepCount] = 0;
// THE ROPE TABLE IS BUILT ON THE HOST IN FLOAT64 and uploaded once, because the reference computes its
// frequencies in float64 and reproducing that on device means double-precision `pow`/`cos` that need not
// agree with the host's libm. A table shorter than the sequence would have the rotation read past it.
if (share_rope == nullptr) { std::vector<float> hc((size_t) max_cells * (s.n_rot / 2)), hs((size_t) max_cells * (s.n_rot / 2)); strata::kernels::build_rope_table((int) s.n_rot, strata::kernels::qsa_freq_base(), (int) max_cells, hc.data(), hs.data()); cudaMemcpy(st.cos_tab, hc.data(), hc.size() * 4, cudaMemcpyHostToDevice); cudaMemcpy(st.sin_tab, hs.data(), hs.size() * 4, cudaMemcpyHostToDevice); }
// the page table starts as the IDENTITY, which is the simplest legal mapping and what a caller that does
// not page at all wants. A real allocator re-points it; nothing outside `kv_append`/`kv_gather` may care.
std::vector<int32_t> tab((size_t) pages); for (int64_t i = 0; i < pages; ++i) tab[(size_t) i] = (int32_t) i; cudaMemcpy(st.page_table, tab.data(), tab.size() * 4, cudaMemcpyHostToDevice); return c.used;}
void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream) { const QsaShapes s = qsa_shapes(g); cudaStream_t cs = (cudaStream_t) stream; if (st.kv_int8) { const size_t cells = (size_t) st.n_pages * s.n_head_kv * s.page_size; cudaMemsetAsync(st.k_q, 0, cells * s.head_dim, cs); cudaMemsetAsync(st.v_q, 0, cells * s.head_dim, cs); cudaMemsetAsync(st.k_scale, 0, cells * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs); cudaMemsetAsync(st.v_scale, 0, cells * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs); } else { cudaMemsetAsync(st.k_pool, 0, (size_t) st.n_pages * s.n_head_kv * s.page_size * s.head_dim * 2, cs); cudaMemsetAsync(st.v_pool, 0, (size_t) st.n_pages * s.n_head_kv * s.page_size * s.head_dim * 2, cs); } cudaMemsetAsync(st.idx_tail, 0, (size_t) (s.idx_block - 1) * s.idx_dim * 4, cs); cudaMemsetAsync(st.idx_dead, 0, (size_t) s.idx_dim * 4, cs); cudaMemsetAsync(st.idx_pooled, 0, (size_t) ((st.max_cells / s.idx_block) + 2) * s.idx_dim * 4, cs);}
uint64_t kv_pool_bytes(const QsaShapes& s, int64_t pages) {
if (g_kv_q4) return (uint64_t) pages * s.page_size * strata::kernels::kv_q4_bytes_per_cell(s) + 64;
return g_kv_int8 ? (uint64_t) pages * s.page_size * strata::kernels::kv_q8_bytes_per_cell(s) + 64
: (uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim * 2 * 2;
}
} // namespace
void qsa_set_kv_resident(int64_t cells) { g_kv_resident = cells > 0 ? cells : 0; }
int64_t qsa_kv_resident() { return g_kv_resident; }
int64_t qsa_kv_resident_min() { return 20480; }
uint64_t qsa_kv_host_bytes() { return g_kv_host_bytes; }
uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_rope, int64_t ring_cells) {
const QsaShapes s = qsa_shapes(g);
const KvPlan p = kv_plan(s, max_cells, ring_cells);
uint64_t n = 0;
n += kv_pool_bytes(s, p.slots) + 4 * 16; // K/V pools (the VRAM slots)
n += (uint64_t) p.pages * 4; // page_table
if (p.mode == 1) n += strata::kernels::kv_stream_map_bytes(p.slots) + 6 * 16; // the residency map
n += (uint64_t) (s.idx_block - 1) * s.idx_dim * 4; // tail
n += (uint64_t) s.idx_dim * 4; // dead
n += (uint64_t) p.pooled_rows * s.idx_dim * 4; // pooled
n += 16; // block_pos
if (with_rope) n += (uint64_t) max_cells * (s.n_rot / 2) * 4 * 2; // cos + sin tables
n += strata::kernels::qsa_step_bytes() + 16; // counts and aligned attention status
n += (uint64_t) s.n_head * 4; // pos_dev
return align_up16(n) + 256;
}
uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st,
const QsaState* share_rope, int64_t ring_cells) {
const QsaShapes s = qsa_shapes(g);
const KvPlan p = kv_plan(s, max_cells, ring_cells);
const int64_t pages = p.pages;
Cursor c{(uint8_t*) base};
st.kv_int8 = g_kv_int8 && !g_kv_q4;
st.kv_q4 = g_kv_q4;
st.kv_mode = p.mode;
st.n_slots = p.slots;
const uint64_t rows = (uint64_t) p.slots * s.n_head_kv * s.page_size; // VRAM rows: the slots
const uint64_t q4_row = strata::kernels::kv_q4_bytes_per_head((int) s.head_dim);
if (st.kv_q4) {
st.k_q4 = c.take<uint8_t>(rows * q4_row);
st.v_q4 = c.take<uint8_t>(rows * q4_row);
} else if (g_kv_int8) {
st.k_q = c.take<int8_t>(rows * s.head_dim);
st.v_q = c.take<int8_t>(rows * s.head_dim);
st.k_scale = c.take<uint16_t>(rows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
st.v_scale = c.take<uint16_t>(rows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
} else {
st.k_pool = c.take<uint16_t>(rows * s.head_dim);
st.v_pool = c.take<uint16_t>(rows * s.head_dim);
}
st.page_table = c.take<int32_t>((uint64_t) pages);
st.n_pages = pages;
st.max_cells = max_cells;
st.map = strata::kernels::KvStreamMap{};
st.map.page_table = st.page_table;
st.map.n_blocks = pages;
st.map.n_slots = p.slots;
if (p.mode == 1) {
st.map.slot_block = c.take<int32_t>((uint64_t) p.slots);
st.map.slot_stamp = c.take<int32_t>((uint64_t) p.slots);
st.map.slot_ref = c.take<int32_t>((uint64_t) p.slots);
st.map.miss_block = c.take<int32_t>((uint64_t) p.slots);
st.map.miss_slot = c.take<int32_t>((uint64_t) p.slots);
st.map.ctl = c.take<int32_t>((uint64_t) strata::kernels::kKvCtlInts);
}
st.idx_tail = c.take<float>((uint64_t) (s.idx_block - 1) * s.idx_dim);
st.idx_dead = c.take<float>((uint64_t) s.idx_dim);
st.idx_pooled_rows = p.pooled_rows;
st.idx_pooled = c.take<float>((uint64_t) p.pooled_rows * s.idx_dim);
st.idx_block_pos = c.take<int32_t>(1);
if (share_rope != nullptr) {
st.cos_tab = share_rope->cos_tab;
st.sin_tab = share_rope->sin_tab;
} else {
st.cos_tab = c.take<float>((uint64_t) max_cells * (s.n_rot / 2));
st.sin_tab = c.take<float>((uint64_t) max_cells * (s.n_rot / 2));
}
st.step = c.take<int32_t>((uint64_t) strata::kernels::kStepCount);
st.attention_status = c.take<int32_t>(1);
st.pos_dev = c.take<int32_t>((uint64_t) s.n_head);
// the pinned staging the uploads copy FROM - see the note on `host_step` in the header
if (cudaHostAlloc((void**) &st.host_step, strata::kernels::qsa_step_bytes() + sizeof(int32_t),
cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess ||
cudaHostAlloc((void**) &st.host_pos, (size_t) s.n_head * 4, cudaHostAllocMapped | cudaHostAllocPortable) !=
cudaSuccess) {
return 0; // the caller sees a zero byte count; a half-built state is worse than none
}
st.host_step[strata::kernels::kStepCount] = 0;
// KV streaming: the authoritative K/V of every cell, pinned and device-mapped, in the identity layout
st.host = strata::kernels::KvHostPools{};
if (p.mode != 0) {
const uint64_t hrows = (uint64_t) pages * s.n_head_kv * s.page_size;
const uint64_t bytes = (uint64_t) pages * strata::kernels::kv_block_bytes(s, qsa_kv_format(st)) + 4 * 256;
uint8_t* h = nullptr;
uint8_t* d = nullptr;
if (cudaHostAlloc((void**) &h, bytes, cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess ||
cudaHostGetDevicePointer((void**) &d, h, 0) != cudaSuccess) {
// under WSL the NVIDIA driver pins only ~1 GiB in all, which is less than 128K of 8-bit KV needs
if (p.mode == 1) std::fprintf(stderr, "strata: KV streaming: cannot pin %.2f GiB of RAM for a layer's KV copy "
"(%.2f GiB pinned so far) - lower the context, or run without --kv-resident (under "
"WSL the driver pins only about 1 GiB in all)\n", (double) bytes / 1073741824.0,
(double) g_kv_host_bytes / 1073741824.0);
return 0;
}
g_kv_host_bytes += bytes;
Cursor hc{d};
if (st.kv_q4) {
st.host.k_q4 = hc.take<uint8_t>(hrows * q4_row);
st.host.v_q4 = hc.take<uint8_t>(hrows * q4_row);
} else if (g_kv_int8) {
st.host.k_q = hc.take<int8_t>(hrows * s.head_dim);
st.host.v_q = hc.take<int8_t>(hrows * s.head_dim);
st.host.k_scale = hc.take<uint16_t>(hrows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
st.host.v_scale = hc.take<uint16_t>(hrows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
} else {
st.host.k_pool = hc.take<uint16_t>(hrows * s.head_dim);
st.host.v_pool = hc.take<uint16_t>(hrows * s.head_dim);
}
}
// THE ROPE TABLE IS BUILT ON THE HOST IN FLOAT64 and uploaded once, because the reference computes its
// frequencies in float64 and reproducing that on device means double-precision `pow`/`cos` that need not
// agree with the host's libm. A table shorter than the sequence would have the rotation read past it.
if (share_rope == nullptr) {
std::vector<float> hc((size_t) max_cells * (s.n_rot / 2)), hs((size_t) max_cells * (s.n_rot / 2));
strata::kernels::build_rope_table((int) s.n_rot, strata::kernels::qsa_freq_base(), (int) max_cells,
hc.data(), hs.data());
cudaMemcpy(st.cos_tab, hc.data(), hc.size() * 4, cudaMemcpyHostToDevice);
cudaMemcpy(st.sin_tab, hs.data(), hs.size() * 4, cudaMemcpyHostToDevice);
}
// the page table starts as the IDENTITY, which is the simplest legal mapping and what a caller that does
// not page at all wants; a streamed state starts with nothing resident, a ring at `block % n_slots`.
if (p.mode == 0) {
std::vector<int32_t> tab((size_t) pages);
for (int64_t i = 0; i < pages; ++i) tab[(size_t) i] = (int32_t) i;
cudaMemcpy(st.page_table, tab.data(), tab.size() * 4, cudaMemcpyHostToDevice);
} else if (p.mode == 1) {
strata::kernels::kv_stream_reset(st.map, nullptr);
} else {
strata::kernels::kv_ring_table(st.page_table, pages, p.slots, nullptr);
}
cudaDeviceSynchronize();
return c.used;
}
void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream) {
const QsaShapes s = qsa_shapes(g);
cudaStream_t cs = (cudaStream_t) stream;
const size_t rows = (size_t) st.n_slots * s.n_head_kv * s.page_size;
if (st.kv_q4) {
cudaMemsetAsync(st.k_q4, 0, rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), cs);
cudaMemsetAsync(st.v_q4, 0, rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), cs);
} else if (st.kv_int8) {
cudaMemsetAsync(st.k_q, 0, rows * s.head_dim, cs);
cudaMemsetAsync(st.v_q, 0, rows * s.head_dim, cs);
cudaMemsetAsync(st.k_scale, 0, rows * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs);
cudaMemsetAsync(st.v_scale, 0, rows * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs);
} else {
cudaMemsetAsync(st.k_pool, 0, rows * s.head_dim * 2, cs);
cudaMemsetAsync(st.v_pool, 0, rows * s.head_dim * 2, cs);
}
// A streamed state starts over with nothing resident. Its host copy is not cleared (GBs over PCIe per new
// conversation): no reader names a cell before this sequence has written it, and a block copied in whole
// carries the unwritten cells past the end, which nothing reads.
if (st.kv_mode == 1) strata::kernels::kv_stream_reset(st.map, stream);
cudaMemsetAsync(st.idx_tail, 0, (size_t) (s.idx_block - 1) * s.idx_dim * 4, cs);
cudaMemsetAsync(st.idx_dead, 0, (size_t) s.idx_dim * 4, cs);
cudaMemsetAsync(st.idx_pooled, 0, (size_t) st.idx_pooled_rows * s.idx_dim * 4, cs);
}
strata::kernels::QsaAttnPools qsa_attn_pools(const QsaState& st) {
strata::kernels::QsaAttnPools pools;
pools.page_table = st.page_table;
if (st.kv_q4) { pools.k_q4 = st.k_q4; pools.v_q4 = st.v_q4; }
else if (st.kv_int8) { pools.k_q = st.k_q; pools.v_q = st.v_q; pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; }
else { pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; }
return pools;
}
void qsa_kv_resolve(const QsaState& st, const ModelGeometry& g, const int32_t* ids, const int32_t* steps, int64_t n_q,
int64_t cap, void* stream) {
if (st.kv_mode != 1) return;
strata::kernels::kv_stream_resolve(st.map, qsa_attn_pools(st), st.host, qsa_kv_format(st), ids, steps, n_q, cap,
qsa_shapes(g), stream);
}
uint64_t qsa_buffers_bytes(const ModelGeometry& g, int64_t max_cells) { const QsaShapes s = qsa_shapes(g); const int64_t cap = strata::kernels::qsa_selection_width(strata::kernels::kTopkMaxCells, s); uint64_t n = 0; n += (uint64_t) q8k_bytes(g.n_embd); n += (uint64_t) (g.n_embd / 32) * 34;
// block_q8_0
n += (uint64_t) g.n_embd * 2; n += (uint64_t) g.n_head * 2 * g.head_dim * 4; n += (uint64_t) g.n_head * g.head_dim * 4; n += (uint64_t) g.n_head_kv * g.head_dim * 4 * 2; n += (uint64_t) g.idx_key_dim * 4; n += (uint64_t) g.idx_q_heads * g.idx_key_dim * 4; n += (uint64_t) max_cells * 4; n += (uint64_t) cap * 4; n += (uint64_t) cap * g.n_head_kv * g.head_dim * 2 * 2; n += (uint64_t) g.n_head * g.head_dim * 4;
@@ -676,7 +863,11 @@ if (!gemv_quantized(*w_attnk, p_k, f_k, b.x_q8_0, b.x_q8k, b.kcur, g.n_embd, g.n
// EVERY ENTRY POINT FROM HERE ON IS THE CAPTURABLE ONE: the per-token counts come from `st.step` and every
// launch is sized from a capacity in `st`/`b`, so this sequence can be captured and replayed. The
// host-scalar wrappers would be correct here today and silently wrong in a graph.
if (st.kv_int8) kv_append_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, st.step, b.kcur, b.vcur, s, stream); else kv_append_step(st.k_pool, st.v_pool, st.page_table, st.step, b.kcur, b.vcur, s, stream); { const uint64_t nvk = (uint64_t) g.n_head_kv * g.head_dim; const uint64_t base = (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim + 2 * nvk + 8; if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.k_pool, base, nvk / 2, stream); if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.v_pool, base + nvk / 2, nvk / 2, stream); dump_slot(dump, g, layer, b.vcur, base + nvk, nvk, stream); dump_slot(dump, g, layer, b.kcur, base + 2 * nvk, nvk, stream); } { const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; if (native_qsa_indexer_enabled()) {
if (st.kv_q4) {
strata::kernels::fwht256_inplace_cuda(b.kcur, g.n_head_kv, stream); // Q4_0: rotated K and V (kv_q4.hpp)
strata::kernels::fwht256_inplace_cuda(b.vcur, g.n_head_kv, stream);
strata::kernels::kv_append_q4_step(st.k_q4, st.v_q4, st.page_table, st.step, b.kcur, b.vcur, s, stream, &st.host);
} else if (st.kv_int8) kv_append_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, st.step, b.kcur, b.vcur, s, stream, &st.host); else kv_append_step(st.k_pool, st.v_pool, st.page_table, st.step, b.kcur, b.vcur, s, stream, &st.host); { const uint64_t nvk = (uint64_t) g.n_head_kv * g.head_dim; const uint64_t base = (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim + 2 * nvk + 8; if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.k_pool, base, nvk / 2, stream); if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.v_pool, base + nvk / 2, nvk / 2, stream); dump_slot(dump, g, layer, b.vcur, base + nvk, nvk, stream); dump_slot(dump, g, layer, b.kcur, base + 2 * nvk, nvk, stream); } { const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; if (native_qsa_indexer_enabled()) {
try {
native_qsa_indexer_append(b.idx_raw, st.step + kStepPos, pos_base,
(const float*) w_ikn->data, RMS_EPS, ib, s, st.max_cells, (float) qsa_freq_base(), stream);
@@ -708,14 +899,15 @@ int64_t max_blocks = (st.max_cells / s.idx_block) + 2;
} else {
qsa_index_step(st.idx_pooled, b.q_idx, nullptr, s, st.step, max_blocks, b.cell_scores, stream); topk_512_step(b.cell_scores, s, cap, st.step, b.ids, stream);
}
// KV streaming: every block the selection names is made resident before anything reads it
qsa_kv_resolve(st, g, b.ids, st.step, 1, cap, stream);
if (st.kv_q4) strata::kernels::fwht256_inplace_cuda(b.qcur, g.n_head, stream); // <Hq, Hk> = <q, k>
if (g_fast_attn && !native_flash_attn_short && dump == nullptr) {
strata::kernels::QsaAttnPools pools;
pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; pools.k_q = st.k_q; pools.v_q = st.v_q;
pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; pools.page_table = st.page_table;
if (!st.kv_int8) { pools.k_q = nullptr; pools.v_q = nullptr; }
const strata::kernels::QsaAttnPools pools = qsa_attn_pools(st);
strata::kernels::qsa_decode_attn_step(b.qcur, pools, b.ids, st.step, cap, s, b.attn_scratch, b.attn, stream);
} else {
if (st.kv_int8) kv_gather_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); else kv_gather_step(st.k_pool, st.v_pool, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); if (native_flash_attn_short) {
if (st.kv_q4) strata::kernels::kv_gather_q4_step(st.k_q4, st.v_q4, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream);
else if (st.kv_int8) kv_gather_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); else kv_gather_step(st.k_pool, st.v_pool, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); if (native_flash_attn_short) {
if (st.max_cells < 1 || st.max_cells > 256 || !st.attention_status || !st.host_step) {
err = v.name("native_flash_attn") + ": short adapter requires context <=256 and persistent status storage";
return false;
@@ -730,6 +922,7 @@ int64_t max_blocks = (st.max_cells / s.idx_block) + 2;
}
} else qsa_attend_step(b.qcur, b.k_scratch, b.v_scratch, st.step, cap, s, b.attn, nullptr, stream);
}
if (st.kv_q4) strata::kernels::fwht256_inplace_cuda(b.attn, g.n_head, stream); // the output back: H is self-inverse
dump_slot(dump, g, layer, b.attn, (uint64_t) 2 * g.n_embd + 2 * g.hc, (uint64_t) g.n_head * g.head_dim, stream); { const uint64_t vs = (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim + (uint64_t) 2 * g.n_head_kv * g.head_dim; dump_slot(dump, g, layer, (const float*) b.v_scratch, vs, (uint64_t) g.n_head_kv * g.head_dim / 2, stream); dump_slot(dump, g, layer, (const float*) b.ids, vs + (uint64_t) g.n_head_kv * g.head_dim / 2, 4, stream); dump_slot(dump, g, layer, (const float*) st.step, vs + (uint64_t) g.n_head_kv * g.head_dim / 2 + 4, 4, stream); }
// ---- 9. Gate the attention output before its projection. Native CUDA keeps F32
// sigmoid/multiply arithmetic and uses Q8_1 for the native quantized projection.
@@ -915,7 +1108,8 @@ bool block_layer_pre(const WeightTable& tables, const ModelGeometry& g, int64_t
// the device half: `ple_block` reading `emb_dev`, and the history shift.
const bool fused = g_fused_gr && stage_prefix == 0 && half == 0 &&
strata::kernels::fused_gr_supported(g.n_embd, g.hc, g.hc_lr);
bool pending_ffn = fused && layer > 0; // layer-1's FFN write has not been applied to R yet
// layer-1's FFN write has not been applied to R yet - unless a control vector follows it, which needs R
bool pending_ffn = fused && layer > 0 && !strata::kernels::cvec().covers(layer - 1);
if (ple != nullptr && ple->ready() && layer == 1 && (half == 0 || half == 1)) {
if (pending_ffn) {
const strata::kernels::GrShapes gs0{g.n_embd, g.hc, g.hc_lr};
@@ -1050,10 +1244,23 @@ st_begin(layer, 5, stream);
if (g_shared_early ? !moe_combine_parts(g, layer, k, mb, parts, bb.block_out, stream, err)
: !moe_finish(tables, g, layer, k, mb, bb.mixed, parts, bb.block_out, stream, err)) return false;
st_end(layer, 5, stream); dump_half(bb, g, layer, bb.block_out, (uint64_t) g.n_embd, g.n_embd, stream); dump_half(bb, g, layer, bb.inject, (uint64_t) 2 * g.n_embd + g.hc, g.hc, stream); st_begin(layer, 6, stream);
if (!(g_fused_gr && strata::kernels::fused_gr_supported(g.n_embd, g.hc, g.hc_lr)))
gr_write(bb.R, bb.block_out, bb.inject, gs, bb.R, stream);
else if (layer == g.n_layers - 1)
gr_write(bb.R, bb.block_out, bb.inject2, gs, bb.R, stream); // materialise R for the head
const bool steer = strata::kernels::cvec().covers(layer); // --control-vector-scaled: after this write
try {
if (!(g_fused_gr && strata::kernels::fused_gr_supported(g.n_embd, g.hc, g.hc_lr))) {
gr_write(bb.R, bb.block_out, bb.inject, gs, bb.R, stream);
if (steer) strata::kernels::cvec_apply(bb.R, layer, 1, g.hc * g.n_embd, nullptr, 0, nullptr, 0, false, stream);
} else if (layer == g.n_layers - 1) {
gr_write(bb.R, bb.block_out, bb.inject2, gs, bb.R, stream); // materialise R for the head
if (steer) strata::kernels::cvec_apply(bb.R, layer, 1, g.hc * g.n_embd, nullptr, 0, nullptr, 0, false, stream);
} else if (steer) {
// the write the next layer's fused read would have folded, then the vector
strata::kernels::cvec_apply(bb.R, layer, 1, g.hc * g.n_embd, bb.block_out, g.n_embd, bb.inject2, g.hc,
true, stream);
}
} catch (const std::exception& e) {
err = std::string("block_layer_post: ") + e.what();
return false;
}
st_end(layer, 6, stream); return true;}
bool block_layer(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t pos, int32_t pos_base, const GdnBuffers& gb, const QsaState& qst, const QsaBuffers& qb, const MoEBuffers& mb, int64_t k, const BlockBuffers& bb, const float* parts, void* stream, std::string& err, const Doorbell* db, const PleRun* ple) {
// The two halves back to back. `parts` must be THIS layer's experts and must be ready before the call -
+38 -10
View File
@@ -7,6 +7,7 @@
#include "strata/kernels/elementwise.hpp"
#include "strata/kernels/fused_gr.hpp"
#include "strata/kernels/gr.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/kv_q8.hpp"
#include "strata/kernels/native_moe.hpp"
#include "strata/kernels/native_mmvq.hpp"
@@ -157,9 +158,23 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
// ---- the layer's own K/V (dense attention: no indexer state is read)
const strata::kernels::QsaShapes s = shapes_of(g);
const int64_t max_cells = ss.qsa_states[0].max_cells;
const uint64_t sb = qsa_state_bytes(g, max_cells, false);
// KV streaming: the drafter only reads its last `window` cells, so with streaming on its K/V is a ring of the
// window (plus the cells a round writes ahead of its queries) over a host copy, refilled on a resume. The host copy
// is pinned after the expert arena has pinned what it could: if it does not fit, the K/V stays whole in VRAM.
int64_t ring = (window > 0 && window < max_cells) ? window + 4 * (int64_t) max_t + 64 : 0;
uint64_t sb = qsa_state_bytes(g, max_cells, false, ring);
if (cudaMalloc(&state_arena_, sb) != cudaSuccess) { err = "mtp: the K/V state does not fit"; return false; }
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[0]) == 0) { err = "mtp: state init failed"; return false; }
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[0], ring) == 0) {
if (st_.kv_mode == 0) { err = "mtp: state init failed"; return false; }
std::fprintf(stderr, "strata mtp: no pinned RAM left for the draft layer's K/V copy; keeping it in VRAM\n");
cudaGetLastError();
cudaFree(state_arena_);
st_ = QsaState{};
ring = -1; // fully resident
sb = qsa_state_bytes(g, max_cells, false, ring);
if (cudaMalloc(&state_arena_, sb) != cudaSuccess) { err = "mtp: the K/V state does not fit"; return false; }
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[0], ring) == 0) { err = "mtp: state init failed"; return false; }
}
qsa_state_zero(st_, g, nullptr);
cudaDeviceSynchronize();
vram_ += sb;
@@ -324,12 +339,17 @@ bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::stri
native_mmvq(GGML_Q8_0, q8("self_attn.v_proj.weight"), xq_, vcur_, (int) N, (int) (NKV * HD), T, cs);
for (int t = 0; t < T; ++t) {
norm_rope(kcur_ + t * NKV * HD, f32("self_attn.k_norm.weight"), (int) NKV, (int) HD, pos + t * NH);
if (st_.kv_int8)
if (st_.kv_q4) { // Q4_0 KV (kv_q4.hpp): rotated K and V
fwht256_inplace_cuda(kcur_ + t * NKV * HD, NKV, cs);
fwht256_inplace_cuda(vcur_ + t * NKV * HD, NKV, cs);
kv_append_q4_step(st_.k_q4, st_.v_q4, st_.page_table, step + t * 4, kcur_ + t * NKV * HD,
vcur_ + t * NKV * HD, s, cs, &st_.host);
} else if (st_.kv_int8)
kv_append_q8_step(st_.k_q, st_.v_q, st_.k_scale, st_.v_scale, st_.page_table, step + t * 4,
kcur_ + t * NKV * HD, vcur_ + t * NKV * HD, s, cs);
kcur_ + t * NKV * HD, vcur_ + t * NKV * HD, s, cs, &st_.host);
else
kv_append_step(st_.k_pool, st_.v_pool, st_.page_table, step + t * 4, kcur_ + t * NKV * HD,
vcur_ + t * NKV * HD, s, cs);
vcur_ + t * NKV * HD, s, cs, &st_.host);
}
if (!full) return true;
native_mmvq(GGML_Q8_0, q8("self_attn.q_proj.weight"), xq_, qfull_, (int) N, (int) (NH * 2 * HD), T, cs);
@@ -341,13 +361,12 @@ bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::stri
return false;
}
norm_rope(qc, f32("self_attn.q_norm.weight"), (int) NH, (int) HD, pos + t * NH);
if (st_.kv_q4) fwht256_inplace_cuda(qc, NH, cs);
}
QsaAttnPools pools;
pools.page_table = st_.page_table;
if (st_.kv_int8) { pools.k_q = st_.k_q; pools.v_q = st_.v_q; pools.k_scale = st_.k_scale; pools.v_scale = st_.v_scale; }
else { pools.k_pool = st_.k_pool; pools.v_pool = st_.v_pool; }
const QsaAttnPools pools = qsa_attn_pools(st_);
if (window_ > 0) window_ids(const_cast<int32_t*>(step), T, (int) window_, ident_, cap_, cs);
qsa_decode_attn_batch(qcur_, pools, ident_, step, cap_, s, attn_scratch_, attn_, T, cs);
if (st_.kv_q4) fwht256_inplace_cuda(attn_, (int64_t) T * NH, cs);
for (int t = 0; t < T; ++t)
native_qsa_gate_apply(attn_ + t * NH * HD, qfull_ + t * NH * 2 * HD, attn32_ + t * NH * HD, (int) NH, (int) HD, cs);
native_quantize_q8_1(attn32_, xq_, (int) (NH * HD), T, cs);
@@ -491,6 +510,15 @@ bool MtpDrafter::capture_step(int j, std::string& err) {
return finish_capture(cs_, ok, step_exec_[j], "step", err);
}
void MtpDrafter::kv_restore(int64_t upto) {
if (st_.kv_mode != 2 || upto <= 0) return;
// the ring's blocks below `upto`, from the host copy: a checkpoint resume may have left later cells in them
const strata::kernels::QsaShapes s = shapes_of(*g_);
const int64_t b1 = (upto + s.page_size - 1) / s.page_size, b0 = std::max<int64_t>(0, b1 - st_.n_slots);
strata::kernels::kv_ring_restore(qsa_attn_pools(st_), st_.host, qsa_kv_format(st_), b0, b1, st_.n_slots, s, cs_);
cudaStreamSynchronize(cs_);
}
bool MtpDrafter::prefill(const float* R_rows, const int32_t* next_tokens, int64_t n, int64_t cell0, std::string& err) {
const Clock::time_point t0 = Clock::now();
const int64_t HCN = g_->hc * g_->n_embd;
@@ -551,7 +579,7 @@ bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t*
if (probs) probs[0] = pj;
int n = 1;
// the chain continues while the last draft is likely enough to be verified
for (int j = 1; j < max_t_ - 1 && pj >= min_p; ++j) {
for (int j = 1; j < std::min(max_t_ - 1, max_drafts_) && pj >= min_p; ++j) {
if (!capture_step(j, err)) return false;
put(max_t_ + j - 1, p + a + j);
std::atomic_thread_fence(std::memory_order_seq_cst);
+31 -5
View File
@@ -29,20 +29,46 @@ namespace {
// A 2 MB-aligned reservation. Large pages first, then the largest alignment the OS will give us for free.
void* reserve(uint64_t bytes, PageBacking& got, std::string& note) {
#ifdef _WIN32
// MEM_LARGE_PAGES needs SeLockMemoryPrivilege. Having it assigned to the account is not enough: the
// PROCESS must enable it in its own token (AdjustTokenPrivileges) before VirtualAlloc, or the call fails.
// An account without the assignment, or a failure to enable, leaves the process as it was: VirtualAlloc
// then refuses and the 4 KB fallback below runs - that is the EXPECTED outcome on a desktop.
{
HANDLE tok = nullptr;
if (OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &tok)) {
TOKEN_PRIVILEGES tp{};
tp.PrivilegeCount = 1;
if (LookupPrivilegeValueW(nullptr, L"SeLockMemoryPrivilege", &tp.Privileges[0].Luid)) {
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
if (!AdjustTokenPrivileges(tok, FALSE, &tp, 0, nullptr, nullptr) && GetLastError() != ERROR_NOT_ALL_ASSIGNED)
(void) 0; // nothing actionable: the large-page attempt below reports the outcome
}
CloseHandle(tok);
}
}
// MEM_LARGE_PAGES needs SeLockMemoryPrivilege; a normal account does not have it and VirtualAlloc then
// fails with ERROR_PRIVILEGE_NOT_HELD. That is the EXPECTED outcome on a desktop, not an error.
SIZE_T large = GetLargePageMinimum();
if (large > 0) {
void* p = VirtualAlloc(nullptr, (SIZE_T) bytes, MEM_RESERVE | MEM_COMMIT | MEM_LARGE_PAGES,
// A/B switch: STRATA_NO_LARGEPAGES=1 skips the large-page attempt, same run, same boot.
if (large > 0 && std::getenv("STRATA_NO_LARGEPAGES") == nullptr) {
// MEM_LARGE_PAGES requires the allocation size to be an exact multiple of the large page size -
// anything else is ERROR_INVALID_PARAMETER (87), which reads like a privilege problem but is not.
// Round up: the slack is under 2 MB and the tail stays unused.
const SIZE_T lbytes = (SIZE_T) (((SIZE_T) bytes + large - 1) / large * large);
void* p = VirtualAlloc(nullptr, lbytes, MEM_RESERVE | MEM_COMMIT | MEM_LARGE_PAGES,
PAGE_READWRITE);
if (p) {
got = PageBacking::LargePages;
note = "large pages (" + std::to_string((unsigned long long) large) + " B)";
return p;
}
note = "large pages refused (GetLargePageMinimum=" + std::to_string((unsigned long long) large) +
", VirtualAlloc error " + std::to_string((unsigned long long) GetLastError()) +
" - needs SeLockMemoryPrivilege); using 4 KB pages";
// 1450 (ERROR_NO_SYSTEM_RESOURCES) is the large-page pool saying no, 87 is a size that is not a
// multiple of the minimum, 1314 is the privilege: without the byte count the three read as one bug.
note = "large pages refused for " + std::to_string((unsigned long long) lbytes) + " B (GetLargePageMinimum=" +
std::to_string((unsigned long long) large) + ", VirtualAlloc error " +
std::to_string((unsigned long long) GetLastError()) + "); using 4 KB pages";
} else if (std::getenv("STRATA_NO_LARGEPAGES") != nullptr) {
note = "large pages skipped (STRATA_NO_LARGEPAGES); using 4 KB pages";
} else {
note = "this system has no large-page minimum; using 4 KB pages";
}
+11 -2
View File
@@ -1,5 +1,6 @@
// src/core/session.cpp - one token through all 48 layers. See the header for why the graphs are per-layer.
#include "strata/core/session.hpp"
#include "strata/core/progress.hpp"
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/elementwise.hpp"
@@ -87,9 +88,12 @@ uint64_t session_init(const ModelGeometry& g, int64_t max_cells, int64_t k, void
s.qsa_buf_arena = take(qsa_buffers_bytes(g, max_cells));
uint8_t* qp = (uint8_t*) s.qsa_state_arena;
// a layer whose pinned RAM could not be had (KV streaming's host copy) is half-built: going on would have the
// attention read null host pointers at the first request ("illegal memory access"), so the session fails here
for (int64_t i = 0; i < g.n_qsa_layers(); ++i)
qsa_state_init(g, max_cells, qp + (i == 0 ? 0 : first + (uint64_t) (i - 1) * rest), s.qsa_states[i],
i == 0 ? nullptr : &s.qsa_states[0]);
if (qsa_state_init(g, max_cells, qp + (i == 0 ? 0 : first + (uint64_t) (i - 1) * rest), s.qsa_states[i],
i == 0 ? nullptr : &s.qsa_states[0]) == 0)
return 0;
qsa_buffers_init(g, max_cells, s.qsa_buf_arena, s.qsa_bufs);
s.moe_arena = take(moe_buffers_bytes(g, k));
@@ -888,6 +892,7 @@ bool session_run_token(const ModelGeometry& g, int64_t pos, int32_t pos_base, Se
const auto t0 = Clock::now();
auto last_flush = t0;
uint32_t spins = 0;
progress_at("token: waiting for the GPU to reach layer", l);
while (*seq < want) {
STRATA_SPIN_PAUSE();
if ((++spins & 1023u) != 0) continue;
@@ -909,6 +914,7 @@ bool session_run_token(const ModelGeometry& g, int64_t pos, int32_t pos_base, Se
}
}
const auto t1 = Clock::now();
progress_at("token: the CPU experts of layer", l);
if (pool != nullptr) pool(user, s.db->h_x_f, s.db->h_ids, s.db->h_weights, g.n_embd, s.k, y_miss_host);
std::atomic_thread_fence(std::memory_order_seq_cst);
_mm_sfence();
@@ -917,8 +923,11 @@ bool session_run_token(const ModelGeometry& g, int64_t pos, int32_t pos_base, Se
tg.ms_wait += std::chrono::duration<double, std::milli>(t1 - t0).count();
tg.ms_pool += std::chrono::duration<double, std::milli>(t2 - t1).count();
}
progress_at("token: waiting for the GPU to finish the token");
const cudaError_t se = cudaStreamSynchronize(cs);
if (se != cudaSuccess) { err = std::string("session_run_token: ") + cudaGetErrorString(se); return false; }
progress_at("decode");
progress_beat();
return true;
}
+87 -9
View File
@@ -11,7 +11,9 @@
#include "strata/kernels/cpu/expert.hpp"
#include "strata/kernels/elementwise.hpp"
#include "strata/kernels/fused_gr.hpp"
#include "strata/kernels/cvec.hpp"
#include "strata/kernels/gr.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/kv_q8.hpp"
#include "strata/kernels/native_mmvq.hpp"
#include "strata/kernels/native_qsa.hpp"
@@ -26,11 +28,13 @@
#include "strata/kernels/rope.hpp"
#include "strata/kernels/s2_expert_grouped.hpp"
#include "strata/kernels/sampler.hpp"
#include "strata/core/progress.hpp"
#include "strata/kernels/shared_expert.hpp"
#include "strata/kernels/verify_kernels.hpp"
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -89,7 +93,23 @@ bool native_of(const WeightRef* w, const std::string& name, std::string& err) {
} // namespace
namespace {
std::atomic<const Verifier*> g_diag_verifier{nullptr};
void diag_active_verifier(std::FILE* f) {
if (const Verifier* v = g_diag_verifier.load()) v->diag(f);
}
} // namespace
void Verifier::diag(std::FILE* f) const {
auto rd = [](const uint32_t* p) { return p ? *(const volatile uint32_t*) p : 0u; };
std::fprintf(f, " verify window: %d tokens at position %lld, host at layer step %u; the GPU rang %u; flags: "
"served %u, plan (A) %u, copies (B) %u\n", last_t_, (long long) last_pos0_, cur_layer_ + 1,
rd(h_seq_), rd(h_flag_), rd(h_flagA_), rd(h_flagB_));
}
Verifier::~Verifier() {
const Verifier* self = this;
g_diag_verifier.compare_exchange_strong(self, nullptr);
if (cs_) cudaStreamSynchronize(cs_);
for (auto& e : exec_)
if (e) cudaGraphExecDestroy(e);
@@ -105,12 +125,16 @@ Verifier::~Verifier() {
bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState& ss, const VerifyHits& hits,
const NativeHead* head, int max_t, std::string& err) {
g_diag_verifier.store(this);
diag_verify_fn().store(&diag_active_verifier);
wt_ = &wt;
g_ = &g;
ss_ = &ss;
hits_ = hits;
head_ = head;
max_t_ = max_t;
sampling_.greedy = true; // a fresh verifier samples greedily until set_sampling says otherwise
sampling_.temperature = 0.0f;
if (max_t < 2 || max_t > strata::kernels::kVerifyMaxT || max_t > strata::kernels::cpu::MAXT) {
err = "verify: the window must hold 2.." + std::to_string(strata::kernels::kVerifyMaxT) + " tokens";
return false;
@@ -328,7 +352,9 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
wi[h] = need(v, (std::string(pfx[h]) + "inject.weight").c_str(), err);
if (!wn[h] || !wd[h] || !wu[h] || !wi[h]) return false;
}
bool pending = l > 0; // the previous layer's FFN write, folded into this layer's first read
// the previous layer's FFN write, folded into this layer's first read (a control vector after it has
// already applied it)
bool pending = l > 0 && !cvec().covers(l - 1);
if (l == 1 && ple_on) {
float* normalized = (float*) ((uint8_t*) ss.ple.scratch + ple_block_scratch_bytes());
for (int t = tb; t < te; ++t) {
@@ -419,15 +445,22 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
native_mmvq(wk->native_type, wk->native_data, xq_, kcur_ + tb * NKV * HD, (int) N, (int) (NKV * HD), n, cs);
native_mmvq(wv->native_type, wv->native_data, xq_, vcur_ + tb * NKV * HD, (int) N, (int) (NKV * HD), n, cs);
for (int t = tb; t < te; ++t) norm_rope(kcur_ + t * NKV * HD, wkn, (int) NKV, (int) HD, pos_ + t * NH);
if (st.kv_q4) { // Q4_0 KV (kv_q4.hpp): K and V rotated before they are stored
fwht256_inplace_cuda(kcur_ + tb * NKV * HD, (int64_t) n * NKV, cs);
fwht256_inplace_cuda(vcur_ + tb * NKV * HD, (int64_t) n * NKV, cs);
}
if (grp == 0) copy_from_mapped(tail_snap_ + (size_t) qi * TS, st.idx_tail, TS, cs);
for (int t = tb; t < te; ++t) {
const int32_t* step_t = step_ + t * kStepCount;
if (st.kv_int8)
if (st.kv_q4)
kv_append_q4_step(st.k_q4, st.v_q4, st.page_table, step_t, kcur_ + t * NKV * HD,
vcur_ + t * NKV * HD, s, cs, &st.host);
else if (st.kv_int8)
kv_append_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, step_t,
kcur_ + t * NKV * HD, vcur_ + t * NKV * HD, s, cs);
kcur_ + t * NKV * HD, vcur_ + t * NKV * HD, s, cs, &st.host);
else
kv_append_step(st.k_pool, st.v_pool, st.page_table, step_t, kcur_ + t * NKV * HD,
vcur_ + t * NKV * HD, s, cs);
vcur_ + t * NKV * HD, s, cs, &st.host);
}
const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos};
for (int t = tb; t < te; ++t)
@@ -444,6 +477,7 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
return false;
}
norm_rope(qc, wqn, (int) NH, (int) HD, pos_ + t * NH);
if (st.kv_q4) fwht256_inplace_cuda(qc, NH, cs); // <Hq, Hk> = <q, k>
}
for (int t = tb; t < te; ++t) {
float* qx = qidx_ + t * IQ * ID;
@@ -454,12 +488,12 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
s, scores_ + (size_t) tb * max_blocks_, cs);
qsa_block_topk(scores_ + (size_t) tb * max_blocks_, step_ + tb * kStepCount, n, max_blocks_, cap_, s,
sel_ + (size_t) tb * cap_, cs);
QsaAttnPools pools;
pools.page_table = st.page_table;
if (st.kv_int8) { pools.k_q = st.k_q; pools.v_q = st.v_q; pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; }
else { pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; }
// KV streaming: the n selections' blocks resident (device-side, inside the graph)
qsa_kv_resolve(st, *g_, sel_ + (size_t) tb * cap_, step_ + tb * kStepCount, n, cap_, cs);
const QsaAttnPools pools = qsa_attn_pools(st);
qsa_decode_attn_batch(qcur_ + tb * NH * HD, pools, sel_ + (size_t) tb * cap_, step_ + tb * kStepCount, cap_,
s, attn_scratch_ + (size_t) tb * attn_scratch_floats_, attn_ + tb * NH * HD, n, cs);
if (st.kv_q4) fwht256_inplace_cuda(attn_ + tb * NH * HD, (int64_t) n * NH, cs); // back: H^-1 = H
for (int t = tb; t < te; ++t) {
if (native_qsa_enabled())
native_qsa_gate_apply(attn_ + t * NH * HD, qfull_ + t * NH * 2 * HD, attn32_ + t * NH * HD,
@@ -558,8 +592,12 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
mb.weights = w_ + t * K; mb.shared = shared_ + t * N;
if (!moe_combine_parts(g, l, K, mb, parts_ + (size_t) t * K * N, bo_ + t * N, cs, err)) return false;
}
if (l == g.n_layers - 1)
if (l == g.n_layers - 1) {
for (int t = tb; t < te; ++t) gr_write(Rt(t), bo_ + t * N, inj2_ + t * HC, gs, Rt(t), cs);
if (cvec().covers(l)) cvec_apply(Rt(tb), l, n, HC * N, nullptr, 0, nullptr, 0, false, cs);
} else if (cvec().covers(l)) {
cvec_apply(Rt(tb), l, n, HC * N, bo_ + tb * N, N, inj2_ + tb * HC, HC, true, cs);
}
return true;
};
@@ -595,6 +633,9 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
return false;
}
}
// Greedy, the default, is recorded here as before (no extra launch or sync per window). A request that
// samples or penalizes is sampled again host-side after the replay (run()) with its own parameters and a
// fresh draw counter: a captured sampler would bake them in and replay the same draws forever.
SamplerParams sp;
sp.greedy = true;
sp.temperature = 0.0f;
@@ -751,6 +792,7 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
const Clock::time_point a = Clock::now();
auto last_flush = a;
uint32_t spins = 0;
progress_at("verify window: waiting for the GPU to reach layer", l);
while (*seq < want) {
_mm_pause();
if ((++spins & 1023u) != 0) continue;
@@ -771,10 +813,12 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
cur_layer_ = want - 1;
set_plan_slot(grp);
const int tb = gtb[grp], n = gte[grp] - gtb[grp];
progress_at("verify window: the CPU experts of layer", l);
if (pool != nullptr)
pool(user, h_x_ + (size_t) tb * g.n_embd, h_ids_ + (size_t) tb * ss.k, n, ss.k,
h_ymiss_ + (size_t) tb * ss.k * g.n_embd, l);
VDBG("layer %lld served\n", (long long) l);
progress_tick();
std::atomic_thread_fence(std::memory_order_seq_cst);
_mm_sfence();
if (*(volatile uint32_t*) h_flagA_ != want) { // the pool did not publish a plan: an empty one
@@ -791,12 +835,46 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
ms_wait += std::chrono::duration<double, std::milli>(b - a).count();
ms_pool += ms_since(b);
}
progress_at("verify window: waiting for the GPU to finish the window (flags A/B/M raised)", (int64_t) T);
const cudaError_t se = cudaStreamSynchronize(cs_);
if (se != cudaSuccess) { err = std::string("verify: ") + cudaGetErrorString(se); return false; }
progress_at("verify window: waiting for the expert copies", (int64_t) T);
cudaStreamSynchronize(copy_); // no host function of this window may raise flag B in the next one
// ---- a sampled or penalized request: the head's sampling again, host-side so its parameters are this call's
// own (a captured kernel would replay the same draws forever). Row t's draw is Philox(seed, pos0 + t): tied to
// the POSITION it samples, not to how the text was cut into windows, so a seed replays the same text whatever
// the drafts were. Exact: a rejected row's draw is discarded, and no kept decision depends on a reused draw.
const bool sampled = !sampling_.greedy && sampling_.temperature > 0.0f;
if (sampled || hist_d_ != nullptr) {
SamplerParams sp = sampling_;
sp.counter = (uint64_t) pos0;
sample_tokens(head_logits_, T, (int) n_vocab_, hist_d_, hist_len_, sp, m_out_, cs_);
if (cudaStreamSynchronize(cs_) != cudaSuccess) { // m_out_ is the mapped h_out_: synced, it is readable
err = "verify: the head sampling failed";
return false;
}
}
for (int t = 0; t < T; ++t) out[t] = ((volatile int32_t*) h_out_)[t];
if (static const bool dbg = std::getenv("STRATA_DBG_NAN") != nullptr; dbg) { // debug: the first non-finite head
static bool reported = false;
if (!reported) {
std::vector<float> h((size_t) T * (size_t) n_vocab_);
cudaMemcpy(h.data(), head_logits_, h.size() * 4, cudaMemcpyDeviceToHost);
for (int t = 0; t < T && !reported; ++t) {
int64_t bad = 0;
for (int64_t v = 0; v < n_vocab_; ++v) bad += !std::isfinite(h[(size_t) t * n_vocab_ + v]);
if (bad) {
reported = true;
std::fprintf(stderr, "strata dbg: verify window at position %lld, row %d: %lld of %lld logits non-finite "
"(token out %d)\n", (long long) pos0, t, (long long) bad, (long long) n_vocab_, out[t]);
}
}
}
}
VDBG("window done\n");
++windows;
progress_at("decode");
progress_beat();
return true;
}
+11 -2
View File
@@ -93,7 +93,16 @@ bool expert_layout_load(const std::string& pack_dir, int64_t n_layers, int64_t n
L.max_blob = 0;
std::string line;
while (std::getline(in, line)) {
if (line.empty() || line[0] == '#') continue;
if (line.empty() || line[0] == '#') {
if (!line.empty() && line[0] == '#') {
// v3 packs record their expert count in the header; a pruned model (GSQ-RCO Coder) ships
// fewer experts than the canonical geometry the caller passes, which is a compile-time
// default, so the header wins.
const size_t at = line.find("(n_expert ");
if (at != std::string::npos) L.n_expert = std::atoll(line.c_str() + at + 10);
}
continue;
}
std::istringstream ss(line);
long long l = -1, gt = -1, dt = -1;
unsigned long long off = 0, blob = 0, go = 0, uo = 0, dox = 0;
@@ -130,7 +139,7 @@ bool expert_layout_load(const std::string& pack_dir, int64_t n_layers, int64_t n
err = "native_experts.txt: layer " + std::to_string(l) + " is missing or not contiguous";
return false;
}
at += L.bytes[(size_t) l] * (uint64_t) n_expert;
at += L.bytes[(size_t) l] * (uint64_t) L.n_expert;
}
L.total = at;
g_layout = L;
+419
View File
@@ -0,0 +1,419 @@
// src/kernels/cpu/iq_avx2.cpp - the i-quant expert rows in 256-bit lanes, several tokens at once.
//
// The AVX-512 kernels' (iq_avx512.cpp) multi-token scheme on the CPUs without AVX-512 (AMD Zen 2/3, Intel
// Core 12th-14th gen and Core Ultra): each 32-value chunk is decoded once - grid lookups, one sign vector,
// one scale vector - and every token of the verify window applies them with a load, a `sign`, a `maddubs`,
// a `madd` and an `add`. ggml-cpu's own AVX2 dot products for these formats are single-token: every token
// re-does the codebook lookups and the sign expansion. The sign vector is ggml's bit_selector pattern
// (ggml-cpu/arch/x86/quants.c): pshufb-broadcast of each sign byte, AND with the bit selector, CMPEQ, OR
// with one, `vpsignb`. The arithmetic is ggml's (ggml-cpu/quants.c, the `_generic` references) - only the
// order of the float additions differs.
//
// Formats: IQ2_XXS (16), IQ2_XS (17), IQ3_XXS (18), IQ3_S (21), IQ2_S (22). IQ1_M stays on ggml-cpu.
#include "strata/kernels/cpu/iq_avx2.hpp"
#define GGML_COMMON_DECL_CPP
#define GGML_COMMON_IMPL_CPP
#include "ggml-common.h"
#include <immintrin.h>
#include <cmath>
#include <cstring>
namespace strata::kernels::cpu {
namespace {
inline float h2f(uint16_t h) { return _mm_cvtss_f32(_mm_cvtph_ps(_mm_cvtsi32_si128((int) h))); }
inline uint32_t u32(const uint8_t* p) { uint32_t v; std::memcpy(&v, p, 4); return v; }
inline uint16_t u16(const uint8_t* p) { uint16_t v; std::memcpy(&v, p, 2); return v; }
inline uint64_t u64(const uint8_t* p) { uint64_t v; std::memcpy(&v, p, 8); return v; }
// 32 sign bits -> 32 bytes of -1 (bit set) / +1: ggml's bit_selector pattern, shared by all tokens.
inline __m256i sgn_vec(uint32_t m) {
const __m128i bm = _mm_set1_epi32((int) m);
const __m128i lo = _mm_shuffle_epi8(bm, _mm_setr_epi8(0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1));
const __m128i hi = _mm_shuffle_epi8(bm, _mm_setr_epi8(2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3));
const __m256i bits = _mm256_inserti128_si256(_mm256_castsi128_si256(lo), hi, 1);
const __m256i sel = _mm256_setr_epi8(1, 2, 4, 8, 16, 32, 64, (char) 0x80,
1, 2, 4, 8, 16, 32, 64, (char) 0x80,
1, 2, 4, 8, 16, 32, 64, (char) 0x80,
1, 2, 4, 8, 16, 32, 64, (char) 0x80);
const __m256i nz = _mm256_cmpeq_epi8(_mm256_and_si256(bits, sel), sel);
return _mm256_or_si256(nz, _mm256_set1_epi8(1));
}
// The two 16-value scales of one 32-value half (IQ2_XS, IQ2_S): int16 lanes 0-7 = a (values 0-15),
// 8-15 = b (values 16-31). NOT a broadcast dword [a,b] - that would alternate the scales lane by lane.
inline __m256i sc16(int a, int b) {
return _mm256_inserti128_si256(_mm256_castsi128_si256(_mm_set1_epi16((short) a)),
_mm_set1_epi16((short) b), 1);
}
// One scale for the whole 32-value half (IQ2_XXS, IQ3_XXS, IQ3_S).
inline __m256i sc32(int s) { return _mm256_set1_epi16(s); }
// keven_signs_q2xs (ggml keeps it static in arch/x86/quants.c): one u64 per 7-bit sign index, byte k
// = 0xFF when bit k of ksigns_iq2xs[i] is set, 0x01 otherwise. With this a whole 32-value sign vector
// is four scalar loads and a set_epi64x - no ksigns byte packing chain and no bit_selector expansion.
// Built once at load time from the shared ggml-common table so it can never drift from it.
struct EvenSigns {
uint64_t v[128];
EvenSigns() {
for (int i = 0; i < 128; ++i) {
uint64_t r = 0;
for (int k = 0; k < 8; ++k)
r |= (uint64_t) (((ksigns_iq2xs[i] >> k) & 1) ? 0xFF : 0x01) << (8 * k);
v[i] = r;
}
}
};
static const EvenSigns even_signs;
inline float hsum8(__m256 v) {
const __m128 lo = _mm256_castps256_ps128(v), hi = _mm256_extractf128_ps(v, 1);
__m128 s = _mm_add_ps(lo, hi);
s = _mm_hadd_ps(s, s);
s = _mm_hadd_ps(s, s);
return _mm_cvtss_f32(s);
}
// ---- per format: one 32-value half (values 64*j + 32*half .. +31) -> grid magnitudes, sign vector, scales
template <int TY> struct Fmt32;
template <> struct Fmt32<16> { // IQ2_XXS: d, qs[32] u16
static constexpr int bytes = 66;
static constexpr float K = 0.125f;
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
const uint8_t* q = b + 2 + 16 * j + 8 * half;
const uint32_t w0 = u32(q), w1 = u32(q + 4);
g = _mm256_set_epi64x((long long) iq2xxs_grid[w0 >> 24], (long long) iq2xxs_grid[(w0 >> 16) & 255],
(long long) iq2xxs_grid[(w0 >> 8) & 255], (long long) iq2xxs_grid[w0 & 255]);
sgn = _mm256_set_epi64x((long long) even_signs.v[(w1 >> 21) & 127], (long long) even_signs.v[(w1 >> 14) & 127],
(long long) even_signs.v[(w1 >> 7) & 127], (long long) even_signs.v[w1 & 127]);
sc = sc32(2 * (int) (w1 >> 28) + 1);
}
};
template <> struct Fmt32<17> { // IQ2_XS: d, qs[32] u16 (9-bit grid index + 7-bit sign index), scales[8]
static constexpr int bytes = 74;
static constexpr float K = 0.125f;
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
uint16_t v[8];
std::memcpy(v, b + 2 + 16 * j, 16);
const int o = 4 * half;
g = _mm256_set_epi64x((long long) iq2xs_grid[v[o + 3] & 511], (long long) iq2xs_grid[v[o + 2] & 511],
(long long) iq2xs_grid[v[o + 1] & 511], (long long) iq2xs_grid[v[o] & 511]);
uint32_t s = 0;
for (int l = 0; l < 4; ++l) s |= (uint32_t) ksigns_iq2xs[v[o + l] >> 9] << (8 * l);
sgn = sgn_vec(s);
const uint8_t sb = b[66 + 2 * j + half];
sc = sc16(2 * (sb & 15) + 1, 2 * (sb >> 4) + 1);
}
};
template <> struct Fmt32<22> { // IQ2_S: d, qs[64] (32 grid bytes, 32 sign bytes), qh[8], scales[8]
static constexpr int bytes = 82;
static constexpr float K = 0.125f;
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
const uint8_t* qs = b + 2 + 8 * j;
const uint8_t h = b[66 + 2 * j + half];
const int o = 4 * half;
g = _mm256_set_epi64x((long long) iq2s_grid[qs[o + 3] | ((h << 2) & 0x300)],
(long long) iq2s_grid[qs[o + 2] | ((h << 4) & 0x300)],
(long long) iq2s_grid[qs[o + 1] | ((h << 6) & 0x300)],
(long long) iq2s_grid[qs[o] | ((h << 8) & 0x300)]);
const uint64_t m = u64(b + 2 + 32 + 8 * j);
sgn = sgn_vec(half ? (uint32_t) (m >> 32) : (uint32_t) m);
const uint8_t sb = b[74 + 2 * j + half];
sc = sc16(2 * (sb & 15) + 1, 2 * (sb >> 4) + 1);
}
};
template <> struct Fmt32<18> { // IQ3_XXS: d, qs[64] grid bytes, 8 x u32 (4 x 7-bit sign index + 4-bit scale)
static constexpr int bytes = 98;
static constexpr float K = 0.25f;
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
const uint8_t* q = b + 2 + 16 * j + 8 * half;
g = _mm256_set_epi32((int) iq3xxs_grid[q[7]], (int) iq3xxs_grid[q[6]], (int) iq3xxs_grid[q[5]], (int) iq3xxs_grid[q[4]],
(int) iq3xxs_grid[q[3]], (int) iq3xxs_grid[q[2]], (int) iq3xxs_grid[q[1]], (int) iq3xxs_grid[q[0]]);
const uint32_t w = u32(b + 2 + 64 + 8 * j + 4 * half);
sgn = _mm256_set_epi64x((long long) even_signs.v[(w >> 21) & 127], (long long) even_signs.v[(w >> 14) & 127],
(long long) even_signs.v[(w >> 7) & 127], (long long) even_signs.v[w & 127]);
sc = sc32(2 * (int) (w >> 28) + 1);
}
};
template <> struct Fmt32<21> { // IQ3_S: d, qs[64], qh[8], signs[32], scales[4]
static constexpr int bytes = 110;
static constexpr float K = 1.0f;
static inline void decode(const uint8_t* b, int j, int half, __m256i& g, __m256i& sgn, __m256i& sc) {
const uint8_t* q = b + 2 + 16 * j + 8 * half;
const uint32_t h = b[66 + 2 * j + half];
#define G3(k) (int) iq3s_grid[q[k] | (((h >> k) & 1u) << 8)]
g = _mm256_set_epi32(G3(7), G3(6), G3(5), G3(4), G3(3), G3(2), G3(1), G3(0));
#undef G3
const uint64_t m = u64(b + 74 + 8 * j);
sgn = sgn_vec(half ? (uint32_t) (m >> 32) : (uint32_t) m);
const uint8_t s = b[106 + j];
sc = sc32(half ? 2 * (s >> 4) + 1 : 2 * (s & 15) + 1);
}
};
template <int TY, int NT>
inline void row_dot(const uint8_t* row, int nblocks, const block_q8_K* const* y, float* res) {
__m256 accf[NT];
for (int t = 0; t < NT; ++t) accf[t] = _mm256_setzero_ps();
for (int i = 0; i < nblocks; ++i) {
const uint8_t* blk = row + (size_t) i * Fmt32<TY>::bytes;
__m256i acci[NT];
for (int t = 0; t < NT; ++t) acci[t] = _mm256_setzero_si256();
for (int j = 0; j < 4; ++j) {
for (int half = 0; half < 2; ++half) {
__m256i g, sgn, sc;
Fmt32<TY>::decode(blk, j, half, g, sgn, sc);
const int off = 64 * j + 32 * half;
for (int t = 0; t < NT; ++t) {
const __m256i yv = _mm256_loadu_si256((const __m256i*) (y[t][i].qs + off));
const __m256i ys = _mm256_sign_epi8(yv, sgn);
acci[t] = _mm256_add_epi32(acci[t], _mm256_madd_epi16(_mm256_maddubs_epi16(g, ys), sc));
}
}
}
const float dx = h2f(u16(blk)) * Fmt32<TY>::K;
for (int t = 0; t < NT; ++t)
accf[t] = _mm256_fmadd_ps(_mm256_set1_ps(dx * y[t][i].d), _mm256_cvtepi32_ps(acci[t]), accf[t]);
}
for (int t = 0; t < NT; ++t) res[t] = hsum8(accf[t]);
}
// ---- IQ2_XS (17) with ggml's vectorized sign decode (arch/x86/quants.c, ggml_vec_dot_iq2_xs_q8_K):
// the 7-bit sign indices never touch ksigns_iq2xs - the 8th sign bit is reconstructed by parity (two
// shifts, a xor and ggml's bit_helper pshufb) and the per-half sign vectors come out of shuffles of one
// 32-byte load. The 8 scale bytes become all 16 half-scales with ggml's unpack trick. The grid lookups
// stay scalar loads into set_epi64x (ggml does the same). Every token still only pays a load, a sign,
// a maddubs, a madd and an add per half, into alternating accumulators.
template <int NT>
inline void row_dot_iq2xs(const uint8_t* row, int nblocks, const block_q8_K* const* y, float* res) {
static const uint8_t bit_sel[32] = {
1, 2, 4, 8, 16, 32, 64, (uint8_t) 0x80, 1, 2, 4, 8, 16, 32, 64, (uint8_t) 0x80,
1, 2, 4, 8, 16, 32, 64, (uint8_t) 0x80, 1, 2, 4, 8, 16, 32, 64, (uint8_t) 0x80 };
static const char shuf_even[32] = { // sign bits of u16 0,1,2,3 (even bytes of the sign register)
0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2,
4, 4, 4, 4, 4, 4, 4, 4, 6, 6, 6, 6, 6, 6, 6, 6 };
static const char shuf_odd[32] = { // sign bits of u16 4,5,6,7
8, 8, 8, 8, 8, 8, 8, 8, 10, 10, 10, 10, 10, 10, 10, 10,
12, 12, 12, 12, 12, 12, 12, 12, 14, 14, 14, 14, 14, 14, 14, 14 };
static const uint8_t bit_help[32] = { // 0x80 when the 4-bit index has odd popcount (parity bit 7)
0x00, (uint8_t) 0x80, (uint8_t) 0x80, 0x00, (uint8_t) 0x80, 0x00, 0x00, (uint8_t) 0x80,
(uint8_t) 0x80, 0x00, 0x00, (uint8_t) 0x80, 0x00, (uint8_t) 0x80, (uint8_t) 0x80, 0x00,
0x00, (uint8_t) 0x80, (uint8_t) 0x80, 0x00, (uint8_t) 0x80, 0x00, 0x00, (uint8_t) 0x80,
(uint8_t) 0x80, 0x00, 0x00, (uint8_t) 0x80, 0x00, (uint8_t) 0x80, (uint8_t) 0x80, 0x00 };
static const uint8_t k_sc_shuffle[128] = { // half h -> scale bytes 2h (lanes 0-7), 2h+1 (lanes 8-15)
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5,
6, 6, 6, 6, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7,
8, 8, 8, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9,
10, 10, 10, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 11, 11, 11,
12, 12, 12, 12, 12, 12, 12, 12, 13, 13, 13, 13, 13, 13, 13, 13,
14, 14, 14, 14, 14, 14, 14, 14, 15, 15, 15, 15, 15, 15, 15, 15 };
const __m256i bsel = _mm256_loadu_si256((const __m256i*) bit_sel);
const __m256i shev = _mm256_loadu_si256((const __m256i*) shuf_even);
const __m256i shod = _mm256_loadu_si256((const __m256i*) shuf_odd);
const __m256i bhelp = _mm256_loadu_si256((const __m256i*) bit_help);
const __m256i m511 = _mm256_set1_epi16(511);
const __m128i m4 = _mm_set1_epi8(0xf), m1 = _mm_set1_epi8(1);
const __m256i one8 = _mm256_set1_epi8(1);
__m256 accf[NT];
for (int t = 0; t < NT; ++t) accf[t] = _mm256_setzero_ps();
for (int i = 0; i < nblocks; ++i) {
const uint8_t* blk = row + (size_t) i * 74;
// the 8 scale bytes -> 16 half-scales of 2*s+1, interleaved [a0, b0, a1, b1, ...] (ggml's unpack)
__m128i st = _mm_set1_epi64x((long long) u64(blk + 66));
st = _mm_unpacklo_epi8(_mm_and_si128(st, m4), _mm_and_si128(_mm_srli_epi16(st, 4), m4));
const __m128i scales = _mm_add_epi8(_mm_slli_epi16(st, 1), m1);
__m256i acc[NT][2];
for (int t = 0; t < NT; ++t) acc[t][0] = acc[t][1] = _mm256_setzero_si256();
for (int jj = 0; jj < 2; ++jj) { // 128 values (16 u16) per step
const __m256i q2 = _mm256_loadu_si256((const __m256i*) (blk + 2 + 32 * jj));
const __m256i p7 = _mm256_srli_epi16(q2, 9); // 7 sign bits per u16, even bytes
const __m256i p3 = _mm256_srli_epi16(q2, 13); // sign index bits 4-6
const __m256i fsb = _mm256_or_si256(p7,
_mm256_shuffle_epi8(bhelp, _mm256_xor_si256(p7, p3))); // + the parity sign bit 7
const __m256i s01 = _mm256_broadcastsi128_si256(_mm256_castsi256_si128(fsb)); // u16 0-7
const __m256i s23 = _mm256_broadcastsi128_si256(_mm256_extracti128_si256(fsb, 1)); // u16 8-15
alignas(32) uint16_t gi[16];
_mm256_store_si256((__m256i*) gi, _mm256_and_si256(q2, m511));
for (int h = 0; h < 4; ++h) { // the four 32-value halves of this group
const int H = 4 * jj + h; // global half index -> scales byte pair
const __m256i g = _mm256_set_epi64x((long long) iq2xs_grid[gi[4 * h + 3]],
(long long) iq2xs_grid[gi[4 * h + 2]],
(long long) iq2xs_grid[gi[4 * h + 1]],
(long long) iq2xs_grid[gi[4 * h]]);
const __m256i sb = _mm256_shuffle_epi8(h < 2 ? s01 : s23, (h & 1) ? shod : shev);
const __m256i sgn = _mm256_or_si256(
_mm256_cmpeq_epi8(_mm256_and_si256(sb, bsel), bsel), one8);
const __m256i sc = _mm256_cvtepi8_epi16(
_mm_shuffle_epi8(scales, _mm_loadu_si128((const __m128i*) k_sc_shuffle + H)));
const int off = 128 * jj + 32 * h;
for (int t = 0; t < NT; ++t) {
const __m256i yv = _mm256_loadu_si256((const __m256i*) (y[t][i].qs + off));
const __m256i ys = _mm256_sign_epi8(yv, sgn);
acc[t][h & 1] = _mm256_add_epi32(acc[t][h & 1],
_mm256_madd_epi16(_mm256_maddubs_epi16(g, ys), sc));
}
}
}
const float dx = h2f(u16(blk)) * 0.125f;
for (int t = 0; t < NT; ++t)
accf[t] = _mm256_fmadd_ps(_mm256_set1_ps(dx * y[t][i].d),
_mm256_cvtepi32_ps(_mm256_add_epi32(acc[t][0], acc[t][1])), accf[t]);
}
for (int t = 0; t < NT; ++t) res[t] = hsum8(accf[t]);
}
template <int TY, int NT>
inline void row_dot_any(const uint8_t* row, int nblocks, const block_q8_K* const* y, float* res) {
if constexpr (TY == 17) row_dot_iq2xs<NT>(row, nblocks, y, res);
else row_dot<TY, NT>(row, nblocks, y, res);
}
template <int TY, int NT>
void gu_rows(const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act, float* const* ff,
int r0, int r1) {
const block_q8_K* y[NT];
for (int t = 0; t < NT; ++t) y[t] = (const block_q8_K*) act[t];
const int nb = n / QK_K;
float g[NT], u[NT];
for (int r = r0; r < r1; ++r) {
row_dot_any<TY, NT>(blob + (size_t) r * gu_row, nb, y, g);
row_dot_any<TY, NT>(blob + up_off + (size_t) r * gu_row, nb, y, u);
for (int t = 0; t < NT; ++t) ff[t][r] = (g[t] / (1.f + std::exp(-g[t]))) * u[t];
}
}
template <int TY, int NT>
void dot_rows(const uint8_t* w, size_t row_bytes, int n, const void* const* act, float* const* out, int r0, int r1) {
const block_q8_K* y[NT];
for (int t = 0; t < NT; ++t) y[t] = (const block_q8_K*) act[t];
float res[NT];
for (int r = r0; r < r1; ++r) {
row_dot_any<TY, NT>(w + (size_t) r * row_bytes, n / QK_K, y, res);
for (int t = 0; t < NT; ++t) out[t][r] = res[t];
}
}
template <int TY>
void gu_rows_nt(int nt, const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act,
float* const* ff, int r0, int r1) {
switch (nt) {
case 1: gu_rows<TY, 1>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 2: gu_rows<TY, 2>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 3: gu_rows<TY, 3>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 4: gu_rows<TY, 4>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 5: gu_rows<TY, 5>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 6: gu_rows<TY, 6>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 7: gu_rows<TY, 7>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
default: gu_rows<TY, 8>(blob, gu_row, up_off, n, act, ff, r0, r1); break;
}
}
template <int TY>
void dot_rows_nt(int nt, const uint8_t* w, size_t row_bytes, int n, const void* const* act, float* const* out, int r0,
int r1) {
switch (nt) {
case 1: dot_rows<TY, 1>(w, row_bytes, n, act, out, r0, r1); break;
case 2: dot_rows<TY, 2>(w, row_bytes, n, act, out, r0, r1); break;
case 3: dot_rows<TY, 3>(w, row_bytes, n, act, out, r0, r1); break;
case 4: dot_rows<TY, 4>(w, row_bytes, n, act, out, r0, r1); break;
default: for (int t0 = 0; t0 < nt; t0 += 4) {
const int k = nt - t0 < 4 ? nt - t0 : 4;
dot_rows_nt<TY>(k, w, row_bytes, n, act + t0, out + t0, r0, r1);
}
}
}
} // namespace
bool iq256_supported(int type) noexcept {
return type == 16 || type == 17 || type == 18 || type == 21 || type == 22;
}
void iq256_gu_rows(int type, const uint8_t* blob, size_t gu_row, size_t up_off, int n, const void* const* act, int nt,
float* const* ff, int r0, int r1) {
switch (type) {
case 16: gu_rows_nt<16>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 17: gu_rows_nt<17>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 18: gu_rows_nt<18>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 21: gu_rows_nt<21>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break;
case 22: gu_rows_nt<22>(nt, blob, gu_row, up_off, n, act, ff, r0, r1); break;
default: break;
}
}
void iq256_rows(int type, const uint8_t* w, size_t row_bytes, int n, const void* const* act, int nt, float* const* out,
int r0, int r1) {
switch (type) {
case 16: dot_rows_nt<16>(nt, w, row_bytes, n, act, out, r0, r1); break;
case 17: dot_rows_nt<17>(nt, w, row_bytes, n, act, out, r0, r1); break;
case 18: dot_rows_nt<18>(nt, w, row_bytes, n, act, out, r0, r1); break;
case 21: dot_rows_nt<21>(nt, w, row_bytes, n, act, out, r0, r1); break;
case 22: dot_rows_nt<22>(nt, w, row_bytes, n, act, out, r0, r1); break;
default: break;
}
}
// ---- IQ4_NL (type 20): 32-value blocks of 18 bytes (f16 d + 16 nibble bytes) against Q8_0 activations.
// ggml-cpu's own AVX-2 dot (ggml_vec_dot_iq4_nl_q8_0) is single-token: every token redoes the nibble ->
// kvalues_iq4nl pshufb decode and the |w| half of the signed x signed product. Here both are computed once
// per block; every token then costs a sign, a maddubs, a madd and an fmadd. The arithmetic is ggml's - only
// the order of the float additions differs.
template <int NT>
void iq4nl_rows(const uint8_t* w, size_t row_bytes, int n, const block_q8_0* const* y, float* const* out,
int r0, int r1) {
const __m128i values = _mm_loadu_si128((const __m128i*) kvalues_iq4nl);
const __m128i m4b = _mm_set1_epi8(0x0f);
const __m256i ones = _mm256_set1_epi16(1);
const int nb = n / QK4_NL;
for (int r = r0; r < r1; ++r) {
const uint8_t* row = w + (size_t) r * row_bytes;
__m256 accf[NT];
for (int t = 0; t < NT; ++t) accf[t] = _mm256_setzero_ps();
for (int ib = 0; ib < nb; ++ib) {
const uint8_t* blk = row + (size_t) ib * sizeof(block_iq4_nl);
const __m128i bits = _mm_loadu_si128((const __m128i*) (blk + 2));
const __m128i lo = _mm_and_si128(bits, m4b); // values 0..15
const __m128i hi = _mm_and_si128(_mm_srli_epi16(bits, 4), m4b); // values 16..31
const __m256i q4 = _mm256_inserti128_si256(_mm256_castsi128_si256(_mm_shuffle_epi8(values, lo)),
_mm_shuffle_epi8(values, hi), 1);
const __m256i aq = _mm256_sign_epi8(q4, q4); // |w|: the unsigned operand of maddubs
const float dx = h2f(u16(blk));
for (int t = 0; t < NT; ++t) {
const block_q8_0& b = y[t][ib];
const __m256i q8 = _mm256_loadu_si256((const __m256i*) b.qs);
const __m256i p = _mm256_madd_epi16(_mm256_maddubs_epi16(aq, _mm256_sign_epi8(q8, q4)), ones);
accf[t] = _mm256_fmadd_ps(_mm256_set1_ps(dx * h2f(b.d)), _mm256_cvtepi32_ps(p), accf[t]);
}
}
for (int t = 0; t < NT; ++t) out[t][r] = hsum8(accf[t]);
}
}
void iq4nl256_down_rows(const uint8_t* w, size_t row_bytes, int n, const void* const* hq, int nt, float* const* out,
int r0, int r1) {
const block_q8_0* y[8];
for (int t = 0; t < nt; ++t) y[t] = (const block_q8_0*) hq[t];
switch (nt) {
case 1: iq4nl_rows<1>(w, row_bytes, n, y, out, r0, r1); break;
case 2: iq4nl_rows<2>(w, row_bytes, n, y, out, r0, r1); break;
case 3: iq4nl_rows<3>(w, row_bytes, n, y, out, r0, r1); break;
case 4: iq4nl_rows<4>(w, row_bytes, n, y, out, r0, r1); break;
default: for (int t0 = 0; t0 < nt; t0 += 4) {
const int k = nt - t0 < 4 ? nt - t0 : 4;
iq4nl256_down_rows(w, row_bytes, n, hq + t0, k, out + t0, r0, r1);
}
}
}
} // namespace strata::kernels::cpu
+22 -5
View File
@@ -4,6 +4,7 @@
#include "strata/kernels/cpu/native_expert.hpp"
#include "strata/kernels/cpu/expert.hpp"
#include "strata/kernels/cpu/iq_avx512.hpp"
#include "strata/kernels/cpu/iq_avx2.hpp"
#include "strata/kernels/cpu/expert_layout.hpp"
#include "ggml.h"
@@ -76,12 +77,21 @@ void native_quant_h(const NativeFmt& f, const float* h, void* dst) {
void native_gu_rows(const NativeFmt& f, const uint8_t* blob, const void* const* act, int nt, float* const* ff,
int r0, int r1) {
// the AVX-512 kernels decode the weights once for all tokens: 2.0-2.4x ggml-cpu at three tokens, no faster at
// one (both are bound by the codebook lookups, ~5 GB/s per core), measured by native_expert_parity
// the multi-token kernels decode the weights once for all tokens: 2.0-2.4x ggml-cpu at three tokens, no faster
// at one (all are bound by the codebook lookups, ~5 GB/s per core), measured by native_expert_parity. AVX-512
// first, then the AVX-2 one (Zen 2/3, Intel 12th-14th gen); STRATA_NO_IQ512 / STRATA_NO_IQ256 fall back to
// ggml-cpu's single-token vec_dot.
static const bool avx512 = cpu_avx512_ok() && std::getenv("STRATA_NO_IQ512") == nullptr;
if (avx512 && nt >= 2 && iq512_supported(f.gu_type)) { // one token: ggml-cpu is as fast or faster
iq512_gu_rows(f.gu_type, blob, f.gu_row, f.up_off, (int) f.n_embd, act, nt, ff, r0, r1);
return;
static const bool avx2 = std::getenv("STRATA_NO_IQ256") == nullptr;
if (nt >= 2 && iq512_supported(f.gu_type)) { // one token: ggml-cpu is as fast or faster
if (avx512) {
iq512_gu_rows(f.gu_type, blob, f.gu_row, f.up_off, (int) f.n_embd, act, nt, ff, r0, r1);
return;
}
if (avx2) {
iq256_gu_rows(f.gu_type, blob, f.gu_row, f.up_off, (int) f.n_embd, act, nt, ff, r0, r1);
return;
}
}
const ggml_vec_dot_t dot = traits(f.gu_type)->vec_dot;
const int n = (int) f.n_embd;
@@ -99,6 +109,13 @@ void native_gu_rows(const NativeFmt& f, const uint8_t* blob, const void* const*
void native_down_rows(const NativeFmt& f, const uint8_t* blob, const void* const* hq, int nt, float* const* out,
int r0, int r1) {
// IQ4_NL down rows: the AVX-2 multi-token kernel decodes the nibbles and absolutises the weights once per
// block instead of once per token; ggml-cpu's dot is single-token. STRATA_NO_IQ4NL falls back to it.
static const bool iq4nl_mt = std::getenv("STRATA_NO_IQ4NL") == nullptr;
if (nt >= 2 && f.d_type == 20 && iq4nl_mt) {
iq4nl256_down_rows(blob + f.down_off, f.d_row, (int) f.n_ff, hq, nt, out, r0, r1);
return;
}
const ggml_vec_dot_t dot = traits(f.d_type)->vec_dot;
const int n = (int) f.n_ff;
for (int r = r0; r < r1; ++r) {
+206 -27
View File
@@ -1,5 +1,6 @@
// src/kernels/cpu/pool.cpp - P2.S3: the CPU expert pool. Read pool.hpp first; it explains the protocol.
#include "strata/kernels/cpu/pool.hpp"
#include "strata/core/progress.hpp"
#include "strata/kernels/cpu/expert_layout.hpp"
#include <algorithm>
@@ -8,6 +9,7 @@
#include <immintrin.h>
#include <cstdio>
#include <cstdlib>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
@@ -19,6 +21,12 @@
namespace strata::kernels::cpu {
namespace {
constexpr uint64_t pack_head(uint32_t epoch, uint32_t n, uint32_t i) {
return ((uint64_t) epoch << 32) | ((uint64_t) n << 16) | (uint64_t) i;
}
} // namespace
std::vector<int> physical_cores(bool skip_first) {
std::vector<int> cores;
#if defined(_WIN32)
@@ -47,13 +55,39 @@ std::vector<int> physical_cores(bool skip_first) {
}
}
#else
// The logical CPUs this process may run on, ONE PER PHYSICAL CORE (issue #40): SMT siblings share a core's
// load/store bandwidth, so a worker on each would put two workers on one core, as the Windows branch above
// explains. sysfs names each CPU's (package, core); the first allowed CPU of each pair is kept, so a taskset
// that leaves out the first sibling still gets its core. Without sysfs every allowed CPU counts, as before.
auto topo = [](int cpu, const char* what) -> long {
char path[96];
std::snprintf(path, sizeof path, "/sys/devices/system/cpu/cpu%d/topology/%s", cpu, what);
long v = -1;
if (std::FILE* f = std::fopen(path, "r")) {
if (std::fscanf(f, "%ld", &v) != 1) v = -1;
std::fclose(f);
}
return v;
};
std::vector<int> allowed;
cpu_set_t set;
CPU_ZERO(&set);
if (sched_getaffinity(0, sizeof set, &set) == 0)
if (sched_getaffinity(0, sizeof set, &set) == 0) {
for (int i = 0; i < CPU_SETSIZE; ++i)
if (CPU_ISSET(i, &set)) cores.push_back(i);
else
for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) cores.push_back((int) i);
if (CPU_ISSET(i, &set)) allowed.push_back(i);
} else {
for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) allowed.push_back((int) i);
}
std::vector<std::pair<long, long>> seen;
for (int cpu : allowed) {
const long pkg = topo(cpu, "physical_package_id"), core = topo(cpu, "core_id");
if (pkg >= 0 && core >= 0) {
const std::pair<long, long> key{pkg, core};
if (std::find(seen.begin(), seen.end(), key) != seen.end()) continue; // an SMT sibling
seen.push_back(key);
}
cores.push_back(cpu);
}
#endif
if (skip_first && !cores.empty()) cores.erase(cores.begin());
return cores;
@@ -107,11 +141,50 @@ void restore_thread_affinity(long long previous) {
#endif
}
namespace {
std::atomic<const ExpertPool*> g_diag_pool{nullptr};
void diag_active_pool(std::FILE* f) {
if (const ExpertPool* p = g_diag_pool.load()) p->diag(f);
}
int64_t now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch())
.count();
}
} // namespace
void ExpertPool::diag(std::FILE* f) const {
const uint64_t h = head_.load();
std::fprintf(f, " expert pool: epoch %u, batch epoch %u: %u of %u jobs claimed, %u done; %u of %d workers parked, "
"%u sleeping; mode %d\n", epoch_.load(), (uint32_t) (h >> 32), (uint32_t) h & 0xffffu,
(uint32_t) (h >> 16) & 0xffffu, done_.load(), parked_.load(), n_, sleepers_.load(), mode_);
std::fprintf(f, " expert pool threads:");
for (int i = 0; i < n_; ++i) {
const int32_t s = wstate_[(size_t) i].load();
if (s == kParked) std::fprintf(f, " w%d=parked", i);
else if (s == kSleeping) std::fprintf(f, " w%d=sleeping", i);
else if (s == kBetween) std::fprintf(f, " w%d=draining", i);
else std::fprintf(f, " w%d=job%d", i, s);
}
const int32_t hs = hstate_.load();
const char* hn = hs == kIdle ? "idle" : hs == kWaitParked ? "waiting for the workers to park"
: hs == kWaitDone ? "waiting for the jobs to finish" : "running a job";
std::fprintf(f, "; host %s", hn);
if (hs >= 0) std::fprintf(f, " %d", hs);
std::fprintf(f, " for %lld ms\n", (long long) (now_ms() - hstate_ms_.load()));
}
ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works) : host_works_(host_works) {
if (const char* e = std::getenv("STRATA_POOL_SPIN_US")) // a test knob; see kSpinBeforeSleep
spin_before_sleep_ = std::chrono::microseconds((std::max)(0, std::atoi(e)));
const std::vector<int> cores = physical_cores(true);
n_ = n_workers > 0 ? n_workers : (int) cores.size();
if (n_ < 1) n_ = 1;
scratch_.resize((size_t) n_);
wstate_.reset(new std::atomic<int32_t>[(size_t) n_]);
for (int i = 0; i < n_; ++i) wstate_[(size_t) i].store(kParked);
hstate_ms_.store(now_ms());
g_diag_pool.store(this);
strata::core::diag_pool_fn().store(&diag_active_pool);
split_.resize((size_t) kMaxSplit);
split_multi_.resize((size_t) kMaxSplitMulti);
threads_.reserve((size_t) n_);
@@ -125,12 +198,27 @@ ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works) : host_works_(h
}
ExpertPool::~ExpertPool() {
const ExpertPool* self = this;
g_diag_pool.compare_exchange_strong(self, nullptr);
stop_.store(true, std::memory_order_release);
// Bump the epoch so a PARKED worker notices the stop flag rather than sleeping through it.
epoch_.fetch_add(1, std::memory_order_release);
publish();
for (auto& t : threads_) t.join();
}
void ExpertPool::publish() {
// Both sides are seq_cst, and that is the whole lost-wakeup argument: a worker going to sleep does
// `sleepers_++` and then reads `epoch_`, the host does `epoch_++` and then reads `sleepers_`. In one total
// order at least one of them sees the other's write - the worker sees the new epoch and does not sleep, or
// the host sees the sleeper and notifies under the mutex the worker holds until it is inside `wait`.
// On x86 the fetch_add is a locked xadd either way, so this costs the token path nothing.
epoch_.fetch_add(1, std::memory_order_seq_cst);
if (sleepers_.load(std::memory_order_seq_cst) != 0) {
std::lock_guard<std::mutex> lk(sleep_mu_);
sleep_cv_.notify_all();
}
}
void ExpertPool::worker(int id) {
uint32_t seen = 0;
// ARRIVE at the park before the first wait, so `parked_ == n_` is true from construction. Counting only
@@ -147,26 +235,115 @@ void ExpertPool::worker(int id) {
// - a locked read-modify-write, five workers against one cache line - so the workers spent their wait
// invalidating each other's caches and the very line the host writes to publish work. The counter was
// diagnostic and nothing branched on it. See the note on the atomics in pool.hpp.
//
// After `kSpinBeforeSleep` with no work the worker sleeps instead (issue #4). The clock is read once
// every 1024 pauses, so the spin itself is unchanged.
const auto parked_at = std::chrono::steady_clock::now();
uint32_t spins = 0;
while (epoch_.load(std::memory_order_acquire) == seen) {
if (stop_.load(std::memory_order_relaxed)) return;
_mm_pause();
if ((++spins & 1023u) != 0) continue;
if (std::chrono::steady_clock::now() - parked_at < spin_before_sleep_) continue;
std::unique_lock<std::mutex> lk(sleep_mu_);
wstate_[(size_t) id].store(kSleeping, std::memory_order_relaxed);
sleepers_.fetch_add(1, std::memory_order_seq_cst);
sleep_cv_.wait(lk, [&] {
return epoch_.load(std::memory_order_seq_cst) != seen || stop_.load(std::memory_order_relaxed);
});
sleepers_.fetch_sub(1, std::memory_order_relaxed);
wstate_[(size_t) id].store(kParked, std::memory_order_relaxed);
}
if (stop_.load(std::memory_order_acquire)) return;
seen = epoch_.load(std::memory_order_relaxed);
// acquire: the batch this epoch published (`head`, and the description before it) is visible from here
seen = epoch_.load(std::memory_order_acquire);
parked_.fetch_sub(1, std::memory_order_acq_rel); // leaving the park
// Drain: one claim per iteration, so a slow worker takes fewer experts and a fast one takes more.
// Every job is the same size (all experts are 1,382,400 bytes), so there is nothing to schedule.
drain(id, scratch_[(size_t) id]);
// Every job is the same size (all experts are 1,382,400 bytes), so there is nothing to schedule. Only
// this epoch's jobs: if the host has already moved on, the claims fail and the worker parks again.
wstate_[(size_t) id].store(kBetween, std::memory_order_relaxed);
drain(id, scratch_[(size_t) id], seen);
wstate_[(size_t) id].store(kParked, std::memory_order_relaxed);
parked_.fetch_add(1, std::memory_order_acq_rel); // back at the park
}
}
void ExpertPool::drain(int id, ExpertScratch& scratch) {
int ExpertPool::claim(uint32_t epoch) {
uint64_t h = head_.load(std::memory_order_acquire);
for (;;) {
if ((uint32_t) (h >> 32) != epoch) return -1; // not the batch this thread woke for
const uint32_t n = (uint32_t) (h >> 16) & 0xffffu, i = (uint32_t) h & 0xffffu;
if (i >= n) return -1; // exhausted
if (head_.compare_exchange_weak(h, h + 1, std::memory_order_acq_rel, std::memory_order_acquire))
return (int) i;
}
}
uint32_t ExpertPool::begin_batch(int n) {
if (n < 0 || n > 0xffff) {
std::fprintf(stderr, "strata: expert pool batch of %d jobs is out of range\n", n);
std::abort();
}
// Every job of the previous batch has completed (`wait_done`), and a claim of it can no longer succeed, so
// nothing adds to `done` until this batch's first claim - which the release below orders after the reset.
done_.store(0, std::memory_order_relaxed);
const uint32_t e = epoch_.load(std::memory_order_relaxed) + 1; // only the host bumps the epoch
head_.store(pack_head(e, (uint32_t) n, 0), std::memory_order_release);
publish();
return e;
}
void ExpertPool::wait_parked(const char* what) {
hstate_.store(kWaitParked, std::memory_order_relaxed);
hstate_ms_.store(now_ms(), std::memory_order_relaxed);
uint32_t spins = 0;
std::chrono::steady_clock::time_point t0{};
while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) {
_mm_pause();
if ((++spins & 1023u) != 0) continue;
const auto now = std::chrono::steady_clock::now();
if (spins == 1024u) t0 = now;
else if (now - t0 > kStall) {
std::fprintf(stderr, "strata: the CPU expert pool stalled %s (%u of %d workers parked) - stopping the engine "
"so the server can start it again (issue #29)\n",
what, parked_.load(), n_);
std::fflush(stderr);
std::abort();
}
}
}
void ExpertPool::wait_done(int n) {
hstate_.store(kWaitDone, std::memory_order_relaxed);
hstate_ms_.store(now_ms(), std::memory_order_relaxed);
uint32_t spins = 0, seen = 0;
std::chrono::steady_clock::time_point t0{};
for (;;) {
const uint32_t d = done_.load(std::memory_order_acquire);
if (d >= (uint32_t) n) return; // `>=`: never a wait that an overshoot outlives
_mm_pause();
if ((++spins & 1023u) != 0) continue;
const auto now = std::chrono::steady_clock::now();
if (spins == 1024u || d != seen) { t0 = now; seen = d; } // progress restarts the clock
else if (now - t0 > kStall) {
std::fprintf(stderr, "strata: the CPU expert pool stalled: %u of %d jobs done, %u of %d workers parked - "
"stopping the engine so the server can start it again (issue #29)\n",
d, n, parked_.load(), n_);
std::fflush(stderr);
std::abort();
}
}
}
void ExpertPool::drain(int id, ExpertScratch& scratch, uint32_t epoch) {
(void) id;
for (;;) {
const uint32_t i = head_.fetch_add(1, std::memory_order_relaxed);
if (i >= (uint32_t) njobs_) break;
const int ci = claim(epoch);
if (ci < 0) break;
const uint32_t i = (uint32_t) ci;
if (id >= 0) wstate_[(size_t) id].store(ci, std::memory_order_relaxed);
else { hstate_.store(ci, std::memory_order_relaxed); hstate_ms_.store(now_ms(), std::memory_order_relaxed); }
if (mode_ == 0) {
const ExpertJob& j = jobs_[i];
s2_expert_vnni_q(j.blob, *j.act, j.out, scratch);
@@ -240,15 +417,15 @@ void ExpertPool::drain(int id, ExpertScratch& scratch) {
}
void ExpertPool::run_phase(int mode, int n_tasks) {
while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause();
wait_parked("before a phase");
mode_ = mode;
njobs_ = n_tasks;
head_.store(0, std::memory_order_relaxed);
done_.store(0, std::memory_order_relaxed);
epoch_.fetch_add(1, std::memory_order_release);
if (host_works_) drain(-1, host_scratch_);
while (done_.load(std::memory_order_acquire) != (uint32_t) n_tasks) _mm_pause();
while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause();
const uint32_t e = begin_batch(n_tasks);
if (host_works_) drain(-1, host_scratch_, e);
wait_done(n_tasks);
wait_parked("after a phase");
hstate_.store(kIdle, std::memory_order_relaxed);
hstate_ms_.store(now_ms(), std::memory_order_relaxed);
}
void ExpertPool::run_split(ExpertJob* jobs, int n) {
@@ -349,14 +526,12 @@ void ExpertPool::run(ExpertJob* jobs, int n) {
// THE THREE PHASES ARE TIMED SEPARATELY. They were one number, which cannot distinguish a pool that is
// slow at the WORK from one that is slow at the SYNCHRONISATION - and those need opposite fixes.
const auto t_a = std::chrono::steady_clock::now();
while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause();
wait_parked("before a batch");
const auto t_b = std::chrono::steady_clock::now();
jobs_ = jobs;
njobs_ = n;
mode_ = 0;
head_.store(0, std::memory_order_relaxed);
done_.store(0, std::memory_order_relaxed);
epoch_.fetch_add(1, std::memory_order_release); // release: jobs_/njobs_ are visible before the bump
const uint32_t e = begin_batch(n); // done, then head (release), then the epoch: the batch is described first
// ---- **THE HOST DRAINS TOO (R2.2), INSTEAD OF SPINNING ON `done_`.**
//
@@ -374,19 +549,23 @@ void ExpertPool::run(ExpertJob* jobs, int n) {
// device after `run()` returns, so the write must be published, not merely performed.
if (host_works_) {
for (;;) {
const uint32_t i = head_.fetch_add(1, std::memory_order_relaxed);
if (i >= (uint32_t) n) break;
const ExpertJob& j = jobs_[i];
const int ci = claim(e);
if (ci < 0) break;
hstate_.store(ci, std::memory_order_relaxed);
hstate_ms_.store(now_ms(), std::memory_order_relaxed);
const ExpertJob& j = jobs_[ci];
s2_expert_vnni_q(j.blob, *j.act, j.out, host_scratch_);
done_.fetch_add(1, std::memory_order_release);
}
}
while (done_.load(std::memory_order_acquire) != (uint32_t) n) _mm_pause();
wait_done(n);
// And park again, so the next `run` starts from a known state. See the header for why `done` alone is
// not enough.
const auto t_c = std::chrono::steady_clock::now();
while (parked_.load(std::memory_order_acquire) != (uint32_t) n_) _mm_pause();
wait_parked("after a batch");
hstate_.store(kIdle, std::memory_order_relaxed);
hstate_ms_.store(now_ms(), std::memory_order_relaxed);
const auto t_d = std::chrono::steady_clock::now();
ms_wait_park_ += std::chrono::duration<double, std::milli>(t_b - t_a).count();
+99
View File
@@ -0,0 +1,99 @@
// src/kernels/cpu/pool_stress.cpp - issue #29: the expert pool under the load a big-VRAM card gives it.
//
// With most experts resident on the GPU, many layers hand the pool nothing, the workers go to sleep in the middle
// of a request, and the next tiny batch (one or two experts) is often finished by the host and the awake workers
// before a sleeper has even woken. That late wake-up is what the park/claim protocol has to survive. This drives
// hundreds of thousands of such batches - 0-4 jobs, run() and run_split(), with pauses long enough for the workers
// to sleep - and checks that every batch returns, with every job's output written. A watchdog thread turns a
// hang into a failure instead of a frozen test.
//
// pool_stress [seconds] (default 20)
#include "strata/kernels/cpu/pool.hpp"
#include "strata/kernels/cpu/expert.hpp"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <thread>
#include <vector>
namespace cpu = strata::kernels::cpu;
int main(int argc, char** argv) {
const double seconds = argc > 1 ? std::atof(argv[1]) : 20.0;
const cpu::CpuFeatures feat = cpu::cpu_features();
if (!feat.usable()) {
std::printf("CPU lacks %s: pool_stress SKIPPED\n", feat.reason());
return 0;
}
constexpr int NJ = 4;
std::vector<std::vector<uint8_t>> blobs(NJ, std::vector<uint8_t>(cpu::BLOB, 0));
std::vector<float> x(cpu::H, 0.25f);
cpu::ActQ act;
cpu::act_quant_q8_1(x.data(), cpu::H, act);
std::vector<float> out((size_t) NJ * cpu::H);
std::vector<cpu::ExpertJob> jobs(NJ);
for (int e = 0; e < NJ; ++e) {
jobs[(size_t) e].blob = blobs[(size_t) e].data();
jobs[(size_t) e].act = &act;
jobs[(size_t) e].out = out.data() + (size_t) e * cpu::H;
jobs[(size_t) e].slot = e;
}
cpu::ExpertPool pool;
std::printf("pool_stress: %d workers + host, %.0f s\n", pool.workers(), seconds);
// the watchdog: a batch that does not return within 10 s is a hang
std::atomic<long long> beat{0};
std::atomic<bool> finished{false};
std::thread dog([&] {
long long last = -1;
auto since = std::chrono::steady_clock::now();
while (!finished.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
const long long b = beat.load();
const auto now = std::chrono::steady_clock::now();
if (b != last) { last = b; since = now; continue; }
if (now - since > std::chrono::seconds(10)) {
std::printf("\npool_stress: HANG - batch %lld has not returned for 10 s\n", b);
std::fflush(stdout);
std::_Exit(3);
}
}
});
std::mt19937 rng(29);
const float SENTINEL = -1.2345e33f;
long long batches = 0, sleeps = 0, missed = 0;
const auto t0 = std::chrono::steady_clock::now();
while (std::chrono::steady_clock::now() - t0 < std::chrono::duration<double>(seconds)) {
// now and then a gap long enough for every worker to go to sleep (the pool sleeps after 20 ms)
if (rng() % 64 == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(21 + (int) (rng() % 10)));
++sleeps;
} else if (rng() % 4 == 0) {
std::this_thread::sleep_for(std::chrono::microseconds(rng() % 300));
}
const int n = (int) (rng() % (NJ + 1));
for (int e = 0; e < n; ++e) std::fill(jobs[(size_t) e].out, jobs[(size_t) e].out + cpu::H, SENTINEL);
if (rng() % 2) pool.run(jobs.data(), n);
else pool.run_split(jobs.data(), n);
for (int e = 0; e < n; ++e)
if (jobs[(size_t) e].out[0] == SENTINEL || jobs[(size_t) e].out[cpu::H - 1] == SENTINEL) ++missed;
beat.fetch_add(1);
++batches;
}
finished.store(true);
dog.join();
std::printf("pool_stress: %lld batches (%lld after the workers slept), %lld jobs not run\n", batches, sleeps,
missed);
if (missed) {
std::printf("pool_stress: FAILED\n");
return 1;
}
std::printf("pool_stress OK\n");
return 0;
}
+128
View File
@@ -0,0 +1,128 @@
// src/kernels/cuda/cvec.cu - see include/strata/kernels/cvec.hpp.
#include "strata/kernels/cvec.hpp"
#include <cuda_runtime.h>
#include <stdexcept>
namespace strata::kernels {
namespace {
constexpr int THREADS = 256;
constexpr int MAXK = 16; // n_embd up to 4096, held in registers between the dot and the update
Cvec g_cvec;
int* g_on = nullptr;
bool g_on_host = false;
// the fused hyper-connection read's gate (fused_gr.cu), so a write done here is bitwise the one it would have folded
__device__ __forceinline__ float sigmoidf_(float x) { return 1.0f / (1.0f + __expf(-x)); }
// one block per (stream, token): the pending write, then h . v over the stream, then the update
__global__ void cvec_kernel(float* __restrict__ R, const float* __restrict__ dir, const float* __restrict__ s_l,
const int* __restrict__ on, int mode, int64_t layer, int n, int hc, int64_t r_ld,
const float* __restrict__ bo, int64_t bo_ld, const float* __restrict__ inj,
int64_t inj_ld, int write) {
const int c = blockIdx.x;
const int64_t t = blockIdx.y;
float* r = R + t * r_ld + (int64_t) c * n;
const float s = s_l[layer];
const bool steer = *on != 0 && s != 0.0f; // uniform over the block
if (!steer && !write) return;
const float* v = dir + layer * n;
const float w = write ? 2.0f * sigmoidf_(inj[t * inj_ld + c] / (float) hc) : 0.0f;
const float* b = write ? bo + t * bo_ld : nullptr;
float x[MAXK];
float dot = 0.0f;
#pragma unroll
for (int k = 0; k < MAXK; ++k) {
const int d = threadIdx.x + k * THREADS;
if (d < n) {
float xv = r[d];
if (write) xv = fmaf(b[d], w, xv);
x[k] = xv;
if (steer && mode == 0) dot = fmaf(xv, v[d], dot);
}
}
if (steer && mode == 0) {
__shared__ float part[THREADS / 32];
#pragma unroll
for (int o = 16; o > 0; o >>= 1) dot += __shfl_xor_sync(0xffffffffu, dot, o);
if ((threadIdx.x & 31) == 0) part[threadIdx.x >> 5] = dot;
__syncthreads();
if (threadIdx.x < 32) {
float p = threadIdx.x < THREADS / 32 ? part[threadIdx.x] : 0.0f;
#pragma unroll
for (int o = 16; o > 0; o >>= 1) p += __shfl_xor_sync(0xffffffffu, p, o);
if (threadIdx.x == 0) part[0] = p;
}
__syncthreads();
dot = part[0] * s; // s (h . v)
}
#pragma unroll
for (int k = 0; k < MAXK; ++k) {
const int d = threadIdx.x + k * THREADS;
if (d < n) {
float xv = x[k];
if (steer) xv = mode == 0 ? fmaf(-dot, v[d], xv) : xv + v[d];
r[d] = xv;
}
}
}
} // namespace
const Cvec& cvec() { return g_cvec; }
bool cvec_upload(const std::vector<float>& dir, const std::vector<float>& s, int mode, int first, int last,
int64_t n_embd, int64_t hc, std::string& err) {
if (n_embd < 1 || n_embd > (int64_t) THREADS * MAXK) { err = "control vector: unsupported n_embd"; return false; }
if (s.empty() || dir.size() != s.size() * (size_t) n_embd) { err = "control vector: bad table sizes"; return false; }
float* d_dir = nullptr;
float* d_s = nullptr;
int* d_on = nullptr;
const int one = 1;
if (cudaMalloc(&d_dir, dir.size() * sizeof(float)) != cudaSuccess ||
cudaMalloc(&d_s, s.size() * sizeof(float)) != cudaSuccess || cudaMalloc(&d_on, sizeof(int)) != cudaSuccess ||
cudaMemcpy(d_dir, dir.data(), dir.size() * sizeof(float), cudaMemcpyHostToDevice) != cudaSuccess ||
cudaMemcpy(d_s, s.data(), s.size() * sizeof(float), cudaMemcpyHostToDevice) != cudaSuccess ||
cudaMemcpy(d_on, &one, sizeof(int), cudaMemcpyHostToDevice) != cudaSuccess) {
err = "control vector: device allocation failed";
return false;
}
g_cvec.dir = d_dir;
g_cvec.s = d_s;
g_cvec.on = d_on;
g_cvec.mode = mode;
g_cvec.first = first;
g_cvec.last = last;
g_cvec.n_embd = n_embd;
g_cvec.hc = hc;
g_cvec.steered.assign(s.size(), false);
for (size_t l = 0; l < s.size(); ++l) g_cvec.steered[l] = s[l] != 0.0f;
g_on = d_on;
g_on_host = true;
return true;
}
void cvec_set_enabled(bool on) {
if (g_on == nullptr || on == g_on_host) return;
cudaDeviceSynchronize(); // nothing in flight may still read the flag
const int v = on ? 1 : 0;
cudaMemcpy(g_on, &v, sizeof(int), cudaMemcpyHostToDevice);
g_on_host = on;
}
bool cvec_enabled() { return g_on != nullptr && g_on_host; }
void cvec_apply(float* R, int64_t layer, int64_t T, int64_t r_ld, const float* bo, int64_t bo_ld, const float* inj,
int64_t inj_ld, bool write, void* stream) {
if (!g_cvec.loaded() || T < 1) return;
const dim3 grid((unsigned) g_cvec.hc, (unsigned) T);
cvec_kernel<<<grid, THREADS, 0, (cudaStream_t) stream>>>(R, g_cvec.dir, g_cvec.s, g_cvec.on, g_cvec.mode, layer,
(int) g_cvec.n_embd, (int) g_cvec.hc, r_ld, bo, bo_ld,
inj, inj_ld, write ? 1 : 0);
if (cudaPeekAtLastError() != cudaSuccess) throw std::runtime_error("cvec_apply: launch failed");
}
} // namespace strata::kernels
+27
View File
@@ -268,6 +268,28 @@ __device__ __forceinline__ float vec_dot_iq4_nl_q8_1(const void* __restrict__ vb
return d * sumi;
}
// IQ4_XS: 256 values as 8 sub-blocks of 32 (6-bit scale each); one call covers one sub-block (iqs = 4 * sub-block),
// and `bq8_1` is the super-block's first q8_1 block, so the call's activation is bq8_1[iqs / 4]. The GSQ-RCO IQ3_S
// file keeps one layer's routed gate/up experts in this format.
__device__ __forceinline__ float vec_dot_iq4_xs_q8_1(const void* __restrict__ vbq, const block_q8_1* __restrict__ bq8_1,
const int& kbx, const int& iqs) {
const block_iq4_xs* bq4 = (const block_iq4_xs*) vbq + kbx;
int sumi = 0;
#pragma unroll
for (int j = 0; j < 4; ++j) {
const int aux_q4 = get_int_b4(bq4->qs, iqs + j);
const int2 v = get_int_from_table_16(aux_q4, kvalues_iq4nl);
const int u0 = get_int_b4(bq8_1[iqs / 4].qs, j + 0);
const int u1 = get_int_b4(bq8_1[iqs / 4].qs, j + 4);
sumi = ggml_cuda_dp4a(v.x, u0, sumi);
sumi = ggml_cuda_dp4a(v.y, u1, sumi);
}
const int ls = ((bq4->scales_l[iqs / 8] >> (iqs & 0x04)) & 0x0F) | (((bq4->scales_h >> (iqs / 2)) & 0x03) << 4);
sumi *= ls - 32;
const float d = __half2float(bq4->d) * __low2float(bq8_1[iqs / 4].ds);
return d * sumi;
}
// ---------------------------------------------------------------- the formats
// qk = values per block, ipb = dot calls per block (qi / vdr), step = the iqs stride between calls.
template<int TY> struct Fmt;
@@ -281,6 +303,8 @@ template<> struct Fmt<20> { static constexpr int qk = 32, ipb = 2, step = 2;
__device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq4_nl_q8_1(v, y, kbx, iqs); } };
template<> struct Fmt<21> { static constexpr int qk = 256, ipb = 8, step = 2;
__device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq3_s_q8_1(v, y, kbx, iqs); } };
template<> struct Fmt<23> { static constexpr int qk = 256, ipb = 8, step = 4;
__device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq4_xs_q8_1(v, y, kbx, iqs); } };
template<> struct Fmt<22> { static constexpr int qk = 256, ipb = 8, step = 2;
__device__ static float dot(const void* v, const block_q8_1* y, int kbx, int iqs) { return vec_dot_iq2_s_q8_1(v, y, kbx, iqs); } };
template<> struct Fmt<29> { static constexpr int qk = 256, ipb = 8, step = 1;
@@ -618,6 +642,7 @@ void iq_mmvq(int t, const void* w, const void* x_q8_1, float* y, int n_in, int n
case 20: mmvq_kernel<20><<<grid, block, 0, s>>>(W, rb, X, y, n_in, n_out, ncols); break;
case 21: mmvq_kernel<21><<<grid, block, 0, s>>>(W, rb, X, y, n_in, n_out, ncols); break;
case 22: mmvq_kernel<22><<<grid, block, 0, s>>>(W, rb, X, y, n_in, n_out, ncols); break;
case 23: mmvq_kernel<23><<<grid, block, 0, s>>>(W, rb, X, y, n_in, n_out, ncols); break;
case 29: mmvq_kernel<29><<<grid, block, 0, s>>>(W, rb, X, y, n_in, n_out, ncols); break;
case 42: mmvq_kernel<42><<<grid, block, 0, s>>>(W, rb, X, y, n_in, n_out, ncols); break;
default: std::fprintf(stderr, "iq_mmvq: type %d is not supported\n", t); std::exit(1);
@@ -700,6 +725,7 @@ void native_expert_grouped(const NativeExpertLayout& L, const unsigned long long
case 18: native_gu_kernel<18><<<ggu, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break;
case 21: native_gu_kernel<21><<<ggu, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break;
case 22: native_gu_kernel<22><<<ggu, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break;
case 23: native_gu_kernel<23><<<ggu, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break;
case 29: native_gu_kernel<29><<<ggu, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break;
case 42: native_gu_kernel<42><<<ggu, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_tok, X, L, gate, up); break;
default: std::fprintf(stderr, "native_expert_grouped: gate/up type %d\n", L.gu_type); std::exit(1);
@@ -711,6 +737,7 @@ void native_expert_grouped(const NativeExpertLayout& L, const unsigned long long
const dim3 gd((unsigned) ((L.n_embd + 7) / 8), (unsigned) cap_groups);
switch (L.d_type) {
case 20: native_down_kernel<20><<<gd, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_dst, hq, L, out); break;
case 23: native_down_kernel<23><<<gd, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_dst, hq, L, out); break;
case 42: native_down_kernel<42><<<gd, 256, 0, s>>>(grp_ptr, grp_start, n_groups, ent_dst, hq, L, out); break;
default: std::fprintf(stderr, "native_expert_grouped: down type %d\n", L.d_type); std::exit(1);
}
+231
View File
@@ -0,0 +1,231 @@
// src/kernels/cuda/kv_q4.cu - see include/strata/kernels/kv_q4.hpp. Q4_0 KV with Walsh-Hadamard rotation
// (from PR #21 by code-martin; KV-streaming integration and the deterministic group maximum added on merge).
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/f16_bits.hpp"
#include "strata/kernels/kv_stream.hpp"
#include <cuda_fp16.h>
#include <cuda_runtime.h>
#include <cstdio>
#include <cstdlib>
namespace strata::kernels {
namespace {
void check(const char* what) {
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
std::fprintf(stderr, "kv_q4: %s: %s\n", what, cudaGetErrorString(e));
std::exit(1);
}
}
// Fast Walsh-Hadamard Transform for N = 256: one warp per row, 8 values per lane in registers.
// Orthonormal (scale 1/sqrt(256) = 1/16), so it is its own inverse.
__global__ void fwht256_kernel(const float* __restrict__ src, float* __restrict__ dst, int64_t n_rows, float scale) {
constexpr int warp_size = 32;
constexpr int N = 256;
constexpr int el_w = N / warp_size; // 8
const int64_t r = (int64_t) blockIdx.x * blockDim.y + threadIdx.y;
if (r >= n_rows) return;
const float* row_src = src + r * N;
float* row_dst = dst + r * N;
float reg[el_w];
const int lane = threadIdx.x;
#pragma unroll
for (int i = 0; i < el_w; ++i) reg[i] = row_src[i * warp_size + lane] * scale;
// the low 5 index bits live across lanes
#pragma unroll
for (int h = 1; h < warp_size; h *= 2) {
#pragma unroll
for (int j = 0; j < el_w; ++j) {
const float val = reg[j];
const float val2 = __shfl_xor_sync(0xffffffffu, val, h, warp_size);
reg[j] = (lane & h) == 0 ? val + val2 : val2 - val;
}
}
// the high 3 bits across each lane's registers
#pragma unroll
for (int h = warp_size; h < N; h *= 2) {
const int step = h / warp_size;
#pragma unroll
for (int j = 0; j < el_w; j += 2 * step) {
#pragma unroll
for (int k = 0; k < step; ++k) {
const float x = reg[j + k];
const float y = reg[j + k + step];
reg[j + k] = x + y;
reg[j + k + step] = x - y;
}
}
}
#pragma unroll
for (int i = 0; i < el_w; ++i) row_dst[i * warp_size + lane] = reg[i];
}
// One 32-value group in one warp (lane = element), ggml's q4_0: d = (the value of largest |x|) / -8,
// q = clamp(trunc(x / d + 8.5), 0, 15). Returns the scale bits; `byte` is lane t's packed byte for t < 16
// (element t in the low nibble, t + 16 in the high one). Ties in |x| resolve to the larger value in EVERY lane,
// so all lanes agree on d (a plain `a > amax` could leave lanes with opposite signs and one block two scales).
__device__ __forceinline__ uint16_t q4_group(float x, int lane, uint8_t& byte) {
float amax = fabsf(x), mval = x;
#pragma unroll
for (int o = 16; o > 0; o >>= 1) {
const float a = __shfl_xor_sync(0xffffffffu, amax, o);
const float v = __shfl_xor_sync(0xffffffffu, mval, o);
if (a > amax || (a == amax && v > mval)) { amax = a; mval = v; }
}
const float d = mval / -8.0f;
const float id = d != 0.0f ? 1.0f / d : 0.0f;
int q = __float2int_rz(x * id + 8.5f);
const uint8_t qc = (uint8_t) (q < 0 ? 0 : (q > 15 ? 15 : q));
const uint8_t qhi = __shfl_down_sync(0xffffffffu, qc, 16);
byte = (uint8_t) (qc | (qhi << 4));
(void) lane;
return f16_from_f32(d);
}
__device__ __forceinline__ void q4_store(uint8_t* pool, long long row, int b, int lane, uint16_t d, uint8_t byte) {
block_q4_0* blk = reinterpret_cast<block_q4_0*>(pool + row * (long long) sizeof(block_q4_0) * 8) + b;
if (lane == 0) blk->d = d;
if (lane < 16) blk->qs[lane] = byte;
}
// One block = one 32-value group of one KV head of K (blockIdx.z = 0) or V (1); 32 threads. KV streaming: the VRAM
// page only if the block is resident (table >= 0), the host copy always (identity layout) when there is one.
__global__ void kv_append_q4_kernel(uint8_t* __restrict__ k_q4, uint8_t* __restrict__ v_q4,
const int32_t* __restrict__ table, const int32_t* __restrict__ step,
const float* __restrict__ kcur, const float* __restrict__ vcur,
int kv_heads, int head_dim, int page_size, KvHostPools host) {
const long long pos = (long long) __ldg(step + kStepPos);
const int h = blockIdx.x, b = blockIdx.y, t = threadIdx.x;
const bool is_v = blockIdx.z == 1;
const float x = (is_v ? vcur : kcur)[h * head_dim + b * QK4_0 + t];
uint8_t byte;
const uint16_t d = q4_group(x, t, byte);
const long long page = (long long) table[pos / page_size];
if (page >= 0) q4_store(is_v ? v_q4 : k_q4, (page * kv_heads + h) * page_size + (pos % page_size), b, t, d, byte);
if (host.k_q4 != nullptr)
q4_store(is_v ? host.v_q4 : host.k_q4, ((pos / page_size) * kv_heads + h) * page_size + (pos % page_size), b, t,
d, byte);
}
// The prompt path: grid (T, kv_heads, groups), K then V; also into the staging pool (identity layout) when given.
__global__ void kv_append_q4_batch_kernel(uint8_t* __restrict__ k_q4, uint8_t* __restrict__ v_q4,
const int32_t* __restrict__ table, int64_t pos0,
const float* __restrict__ K, const float* __restrict__ V,
int kv_heads, int head_dim, int page_size, int is_v_grid, KvHostPools host,
KvHostPools stage) {
const long long t = blockIdx.x;
const long long pos = pos0 + t;
const int h = blockIdx.y, b = blockIdx.z, th = threadIdx.x;
const bool is_v = is_v_grid != 0;
const float x = (is_v ? V : K)[t * (kv_heads * head_dim) + h * head_dim + b * QK4_0 + th];
uint8_t byte;
const uint16_t d = q4_group(x, th, byte);
const long long page = (long long) table[pos / page_size];
const long long row_id = ((pos / page_size) * kv_heads + h) * page_size + (pos % page_size);
if (page >= 0) q4_store(is_v ? v_q4 : k_q4, (page * kv_heads + h) * page_size + (pos % page_size), b, th, d, byte);
if (host.k_q4 != nullptr) q4_store(is_v ? host.v_q4 : host.k_q4, row_id, b, th, d, byte);
if (stage.k_q4 != nullptr) q4_store(is_v ? stage.v_q4 : stage.k_q4, row_id, b, th, d, byte);
}
// Gather step[kStepWidth] cells into FP16 scratch (the non-fused attention paths)
__global__ void kv_gather_q4_kernel(const uint8_t* __restrict__ k_q4, const uint8_t* __restrict__ v_q4,
const int32_t* __restrict__ table, const int32_t* __restrict__ ids,
const int32_t* __restrict__ step, int kv_heads, int head_dim, int page_size,
uint16_t* __restrict__ k_scratch, uint16_t* __restrict__ v_scratch) {
const long long n_ids = (long long) __ldg(step + kStepWidth);
const int blocks_per_head = head_dim / QK4_0; // 8
const int bytes_per_head = blocks_per_head * sizeof(block_q4_0); // 144
const long long total_blocks = n_ids * kv_heads * blocks_per_head;
const long long blk_idx = (long long) blockIdx.x * blockDim.y + threadIdx.y;
if (blk_idx >= total_blocks) return;
const int t = threadIdx.x;
const long long id = blk_idx / (kv_heads * blocks_per_head);
const int rem = (int) (blk_idx % (kv_heads * blocks_per_head));
const int h = rem / blocks_per_head;
const int b = rem % blocks_per_head;
const int cell = ids[id];
const long long page = (long long) table[cell / page_size];
const long long row = (page * kv_heads + h) * page_size + (cell % page_size);
const block_q4_0* k_blk = reinterpret_cast<const block_q4_0*>(k_q4 + row * bytes_per_head) + b;
const block_q4_0* v_blk = reinterpret_cast<const block_q4_0*>(v_q4 + row * bytes_per_head) + b;
const float kd = f32_from_f16(k_blk->d);
const float vd = f32_from_f16(v_blk->d);
const int j = t < 16 ? t : (t - 16);
const uint8_t k_byte = k_blk->qs[j];
const uint8_t v_byte = v_blk->qs[j];
const int kq = (t < 16) ? ((k_byte & 0x0F) - 8) : ((k_byte >> 4) - 8);
const int vq = (t < 16) ? ((v_byte & 0x0F) - 8) : ((v_byte >> 4) - 8);
const long long dst_offset = ((id * kv_heads + h) * head_dim) + (b * QK4_0 + t);
k_scratch[dst_offset] = f16_from_f32((float) kq * kd);
v_scratch[dst_offset] = f16_from_f32((float) vq * vd);
}
void need_256(const QsaShapes& s, const char* what) {
if (s.head_dim != 256) {
std::fprintf(stderr, "%s: head_dim must be 256 (the Hadamard transform's size)\n", what);
std::exit(1);
}
}
} // namespace
void fwht256_cuda(const float* src, float* dst, int64_t n_rows, void* stream) {
if (n_rows <= 0) return;
const int rows_per_block = 4;
const int64_t num_blocks = (n_rows + rows_per_block - 1) / rows_per_block;
fwht256_kernel<<<dim3((unsigned) num_blocks), dim3(32, rows_per_block), 0, (cudaStream_t) stream>>>(
src, dst, n_rows, 1.0f / 16.0f);
check("fwht256 launch");
}
void kv_append_q4_step(uint8_t* k_q4, uint8_t* v_q4, const int32_t* page_table, const int32_t* step,
const float* kcur, const float* vcur, const QsaShapes& s, void* stream,
const KvHostPools* host) {
need_256(s, "kv_append_q4");
const dim3 grid((unsigned) s.n_head_kv, (unsigned) (s.head_dim / QK4_0), 2);
kv_append_q4_kernel<<<grid, 32, 0, (cudaStream_t) stream>>>(
k_q4, v_q4, page_table, step, kcur, vcur, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size,
host ? *host : KvHostPools{});
check("kv_append_q4 launch");
}
void kv_append_q4(uint8_t* k_q4, uint8_t* v_q4, const int32_t* page_table, int64_t pos0, int64_t T, const float* K,
const float* V, const QsaShapes& s, void* stream, const KvHostPools* host, const KvHostPools* stage) {
if (T <= 0) return;
need_256(s, "kv_append_q4");
const dim3 grid((unsigned) T, (unsigned) s.n_head_kv, (unsigned) (s.head_dim / QK4_0));
cudaStream_t cs = (cudaStream_t) stream;
const KvHostPools h = host ? *host : KvHostPools{}, st = stage ? *stage : KvHostPools{};
for (int is_v = 0; is_v < 2; ++is_v)
kv_append_q4_batch_kernel<<<grid, 32, 0, cs>>>(k_q4, v_q4, page_table, pos0, K, V, (int) s.n_head_kv,
(int) s.head_dim, (int) s.page_size, is_v, h, st);
check("kv_append_q4 batch launch");
}
void kv_gather_q4_step(const uint8_t* k_q4, const uint8_t* v_q4, const int32_t* page_table, const int32_t* ids,
const int32_t* step, int64_t max_ids, const QsaShapes& s, uint16_t* k_scratch,
uint16_t* v_scratch, void* stream) {
if (max_ids <= 0) return;
const int blocks_per_head = (int) (s.head_dim / QK4_0);
const int64_t total_blocks = max_ids * s.n_head_kv * blocks_per_head;
const int rows_per_block = 4;
const unsigned num_blocks = (unsigned) ((total_blocks + rows_per_block - 1) / rows_per_block);
kv_gather_q4_kernel<<<dim3(num_blocks), dim3(32, rows_per_block), 0, (cudaStream_t) stream>>>(
k_q4, v_q4, page_table, ids, step, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size, k_scratch,
v_scratch);
check("kv_gather_q4 launch");
}
} // namespace strata::kernels
+15 -6
View File
@@ -31,7 +31,7 @@ __global__ void kv_append_q8_kernel(int8_t* __restrict__ k_q, int8_t* __restrict
uint16_t* __restrict__ k_scale, uint16_t* __restrict__ v_scale,
const int32_t* __restrict__ table, const int32_t* __restrict__ step,
const float* __restrict__ kcur, const float* __restrict__ vcur, int kv_heads,
int head_dim, int page_size) {
int head_dim, int page_size, KvHostPools host) {
const long long pos = (long long) __ldg(step + kStepPos);
const int h = blockIdx.x, g = blockIdx.y, t = threadIdx.x;
const bool is_v = blockIdx.z == 1;
@@ -51,10 +51,18 @@ __global__ void kv_append_q8_kernel(int8_t* __restrict__ k_q, int8_t* __restrict
q = __float2int_rn(x / sf);
q = q < -127 ? -127 : (q > 127 ? 127 : q);
}
// KV streaming: the host copy (identity layout) always, the VRAM page only if the block is resident
const long long page = (long long) table[pos / page_size];
const long long row = (page * kv_heads + h) * page_size + (pos % page_size);
(is_v ? v_q : k_q)[row * head_dim + g * KV_Q8_GROUP + t] = (int8_t) q;
if (t == 0) (is_v ? v_scale : k_scale)[row * groups + g] = sbits;
if (page >= 0) {
const long long row = (page * kv_heads + h) * page_size + (pos % page_size);
(is_v ? v_q : k_q)[row * head_dim + g * KV_Q8_GROUP + t] = (int8_t) q;
if (t == 0) (is_v ? v_scale : k_scale)[row * groups + g] = sbits;
}
if (host.k_q != nullptr) {
const long long row = ((pos / page_size) * kv_heads + h) * page_size + (pos % page_size);
(is_v ? host.v_q : host.k_q)[row * head_dim + g * KV_Q8_GROUP + t] = (int8_t) q;
if (t == 0) (is_v ? host.v_scale : host.k_scale)[row * groups + g] = sbits;
}
}
// One thread = 4 consecutive values of one cell and head (as the FP16 gather does with uint2).
@@ -93,12 +101,13 @@ __global__ void kv_gather_q8_kernel(const int8_t* __restrict__ k_q, const int8_t
} // namespace
void kv_append_q8_step(int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale, const int32_t* page_table,
const int32_t* step, const float* kcur, const float* vcur, const QsaShapes& s, void* stream) {
const int32_t* step, const float* kcur, const float* vcur, const QsaShapes& s, void* stream,
const KvHostPools* host) {
validate(s, "kv_append_q8");
const dim3 grid((unsigned) s.n_head_kv, (unsigned) (s.head_dim / KV_Q8_GROUP), 2);
kv_append_q8_kernel<<<grid, KV_Q8_GROUP, 0, (cudaStream_t) stream>>>(
k_q, v_q, k_scale, v_scale, page_table, step, kcur, vcur, (int) s.n_head_kv, (int) s.head_dim,
(int) s.page_size);
(int) s.page_size, host ? *host : KvHostPools{});
check("kv_append_q8 launch");
}
+258
View File
@@ -0,0 +1,258 @@
// src/kernels/cuda/kv_stream.cu - see include/strata/kernels/kv_stream.hpp.
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/kv_q8.hpp"
#include <cuda_runtime.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
namespace strata::kernels {
namespace {
void check(const char* what) {
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
std::fprintf(stderr, "kv_stream: %s: %s\n", what, cudaGetErrorString(e));
std::exit(1);
}
}
constexpr int RT = 1024; // the resolve block
// The per-block byte runs of the (up to four) pool arrays: block b of array i is bytes [b * len, (b + 1) * len).
struct Runs {
const uint8_t* src[4];
uint8_t* dst[4];
int len[4];
int n;
};
Runs runs_of(const QsaAttnPools& slots, const KvHostPools& host, int fmt, const QsaShapes& s) {
const int rows = (int) (s.n_head_kv * s.page_size);
Runs r{};
if (fmt == kKvQ4) {
const int bytes = rows * (int) kv_q4_bytes_per_head((int) s.head_dim);
r.src[0] = (const uint8_t*) host.k_q4; r.dst[0] = (uint8_t*) slots.k_q4; r.len[0] = bytes;
r.src[1] = (const uint8_t*) host.v_q4; r.dst[1] = (uint8_t*) slots.v_q4; r.len[1] = bytes;
r.n = 2;
} else if (fmt == kKvInt8) {
const int codes = rows * (int) s.head_dim, scales = rows * (int) (s.head_dim / KV_Q8_GROUP) * 2;
r.src[0] = (const uint8_t*) host.k_q; r.dst[0] = (uint8_t*) slots.k_q; r.len[0] = codes;
r.src[1] = (const uint8_t*) host.v_q; r.dst[1] = (uint8_t*) slots.v_q; r.len[1] = codes;
r.src[2] = (const uint8_t*) host.k_scale; r.dst[2] = (uint8_t*) slots.k_scale; r.len[2] = scales;
r.src[3] = (const uint8_t*) host.v_scale; r.dst[3] = (uint8_t*) slots.v_scale; r.len[3] = scales;
r.n = 4;
} else {
const int bytes = rows * (int) s.head_dim * 2;
r.src[0] = (const uint8_t*) host.k_pool; r.dst[0] = (uint8_t*) slots.k_pool; r.len[0] = bytes;
r.src[1] = (const uint8_t*) host.v_pool; r.dst[1] = (uint8_t*) slots.v_pool; r.len[1] = bytes;
r.n = 2;
}
return r;
}
// Block-wide exclusive prefix sum of one int per thread (RT threads); `total` is the sum over the block.
__device__ int block_scan(int v, int* warp_sums, int& total) {
const int lane = threadIdx.x & 31, w = threadIdx.x >> 5;
int x = v;
for (int o = 1; o < 32; o <<= 1) {
const int y = __shfl_up_sync(0xffffffffu, x, o);
if (lane >= o) x += y;
}
if (lane == 31) warp_sums[w] = x;
__syncthreads();
if (w == 0) {
int t = warp_sums[lane];
for (int o = 1; o < 32; o <<= 1) {
const int y = __shfl_up_sync(0xffffffffu, t, o);
if (lane >= o) t += y;
}
warp_sums[lane] = t;
}
__syncthreads();
total = warp_sums[31];
const int excl = x - v + (w > 0 ? warp_sums[w - 1] : 0);
__syncthreads();
return excl;
}
__global__ void __launch_bounds__(RT) resolve_kernel(KvStreamMap m, const int32_t* __restrict__ ids,
const int32_t* __restrict__ steps, int n_q, int cap, int page_size) {
__shared__ int s_nmiss, s_lookups, s_cut;
__shared__ int warp_sums[32];
const int epoch = m.ctl[0] + 1;
if (threadIdx.x == 0) { s_nmiss = 0; s_lookups = 0; }
__syncthreads();
// 1. hits take this epoch and their reference bit; a missing block is claimed exactly once (-1 -> -2)
int lookups = 0;
for (int q = 0; q < n_q; ++q) {
const int width = steps[q * kStepCount + kStepWidth];
const int32_t* qi = ids + (long long) q * cap;
for (int i = threadIdx.x; i < width; i += RT) {
const int b = qi[i] / page_size;
if (i > 0 && qi[i - 1] / page_size == b) continue; // ids are ascending: one lookup per block
++lookups;
const int sl = m.page_table[b];
if (sl >= 0) {
m.slot_stamp[sl] = epoch;
m.slot_ref[sl] = 1;
} else if (sl == -1 && atomicCAS(&m.page_table[b], -1, -2) == -1) {
m.miss_block[atomicAdd(&s_nmiss, 1)] = b;
}
}
}
atomicAdd(&s_lookups, lookups);
__syncthreads();
// 2. one victim per miss: a clock sweep from the hand. A slot this call uses (stamp == epoch) is never taken;
// a referenced one loses its bit as the hand passes it and is taken on the next pass.
const int need = s_nmiss, n = (int) m.n_slots;
int hand = m.ctl[1], got = 0;
for (int scanned = 0; got < need && scanned < 3 * n; scanned += RT) {
const int j = (int) (((long long) hand + threadIdx.x) % n);
const bool mine = m.slot_stamp[j] == epoch;
const bool cand = !mine && (m.slot_block[j] < 0 || m.slot_ref[j] == 0);
int total = 0;
const int rank = block_scan(cand ? 1 : 0, warp_sums, total);
const int want = need - got;
if (threadIdx.x == 0) s_cut = RT;
__syncthreads();
if (cand && rank == want - 1) s_cut = threadIdx.x + 1; // the hand stops just past the last slot taken
__syncthreads();
const int cut = s_cut;
if (cand && rank < want) {
m.miss_slot[got + rank] = j;
m.slot_stamp[j] = epoch; // taken: a sweep that wraps around must not take it twice
} else if (threadIdx.x < cut && !mine) {
m.slot_ref[j] = 0;
}
got += total < want ? total : want;
hand = (int) (((long long) hand + cut) % n);
__syncthreads();
}
// 3. re-point the table; the copy kernel fills the slots
const int placed = got < need ? got : need;
for (int k = threadIdx.x; k < need; k += RT) {
const int b = m.miss_block[k];
if (k >= placed) { m.page_table[b] = -1; continue; } // overflow: never happens with a legal n_slots
const int sl = m.miss_slot[k];
const int old = m.slot_block[sl];
if (old >= 0) m.page_table[old] = -1;
m.slot_block[sl] = b;
m.slot_stamp[sl] = epoch;
m.slot_ref[sl] = 1;
m.page_table[b] = sl;
}
if (threadIdx.x == 0) {
m.ctl[0] = epoch;
m.ctl[1] = hand;
m.ctl[2] = placed;
if (placed < need) m.ctl[3] = 1;
unsigned long long* c = reinterpret_cast<unsigned long long*>(m.ctl + 4);
c[0] += (unsigned long long) placed;
c[1] += (unsigned long long) s_lookups;
c[2] += 1ull;
}
}
// One block per missed block (grid-stride): copy its runs from the host copy into its slot, 16 B per thread.
__global__ void copy_kernel(KvStreamMap m, Runs r) {
const int need = m.ctl[2];
for (int k = blockIdx.x; k < need; k += gridDim.x) {
const long long b = m.miss_block[k], sl = m.miss_slot[k];
for (int a = 0; a < r.n; ++a) {
const uint4* src = reinterpret_cast<const uint4*>(r.src[a] + b * r.len[a]);
uint4* dst = reinterpret_cast<uint4*>(r.dst[a] + sl * r.len[a]);
for (int i = threadIdx.x; i < r.len[a] / 16; i += blockDim.x) dst[i] = src[i];
}
}
}
__global__ void reset_kernel(KvStreamMap m) {
const long long i0 = (long long) blockIdx.x * blockDim.x + threadIdx.x, st = (long long) gridDim.x * blockDim.x;
for (long long i = i0; i < m.n_blocks; i += st) m.page_table[i] = -1;
for (long long i = i0; i < m.n_slots; i += st) {
m.slot_block[i] = -1;
m.slot_stamp[i] = -1;
m.slot_ref[i] = 0;
}
if (i0 < kKvCtlInts) m.ctl[i0] = 0;
}
__global__ void ring_kernel(int32_t* table, long long n_blocks, long long n_slots) {
for (long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; i < n_blocks;
i += (long long) gridDim.x * blockDim.x)
table[i] = (int32_t) (i % n_slots);
}
} // namespace
uint64_t kv_block_bytes(const QsaShapes& s, int fmt) {
const uint64_t rows = (uint64_t) (s.n_head_kv * s.page_size);
if (fmt == kKvQ4) return rows * kv_q4_bytes_per_head((int) s.head_dim) * 2;
return fmt == kKvInt8 ? rows * (uint64_t) s.head_dim * 2 + rows * (uint64_t) (s.head_dim / KV_Q8_GROUP) * 2 * 2
: rows * (uint64_t) s.head_dim * 2 * 2;
}
void kv_stream_reset(const KvStreamMap& m, void* stream) {
reset_kernel<<<128, 256, 0, (cudaStream_t) stream>>>(m);
check("reset");
}
void kv_stream_resolve(const KvStreamMap& m, const QsaAttnPools& slots, const KvHostPools& host, int fmt,
const int32_t* ids, const int32_t* steps, int64_t n_q, int64_t cap, const QsaShapes& s,
void* stream) {
if (n_q <= 0) return;
if (s.n_head_kv * s.page_size * (s.head_dim / KV_Q8_GROUP) * 2 % 16 != 0) {
std::fprintf(stderr, "kv_stream: a block's scale run must be a multiple of 16 bytes\n");
std::exit(1);
}
resolve_kernel<<<1, RT, 0, (cudaStream_t) stream>>>(m, ids, steps, (int) n_q, (int) cap, (int) s.page_size);
check("resolve");
copy_kernel<<<96, 128, 0, (cudaStream_t) stream>>>(m, runs_of(slots, host, fmt, s));
check("copy");
}
void kv_ring_table(int32_t* page_table, int64_t n_blocks, int64_t n_slots, void* stream) {
ring_kernel<<<64, 256, 0, (cudaStream_t) stream>>>(page_table, n_blocks, n_slots);
check("ring table");
}
void kv_ring_restore(const QsaAttnPools& slots, const KvHostPools& host, int fmt, int64_t b0, int64_t b1,
int64_t n_slots, const QsaShapes& s, void* stream) {
const Runs r = runs_of(slots, host, fmt, s);
for (int64_t b = b0; b < b1;) {
const int64_t sl = b % n_slots, run = std::min<int64_t>(b1 - b, n_slots - sl); // up to the ring's end
for (int a = 0; a < r.n; ++a)
if (cudaMemcpyAsync(r.dst[a] + sl * r.len[a], r.src[a] + b * r.len[a], (size_t) (run * r.len[a]),
cudaMemcpyDefault, (cudaStream_t) stream) != cudaSuccess)
check("ring restore");
b += run;
}
}
void kv_stage_from_host(const QsaAttnPools& stage, const KvHostPools& host, int fmt, int64_t n_blocks,
const QsaShapes& s, void* stream) {
if (n_blocks <= 0) return;
const Runs r = runs_of(stage, host, fmt, s);
for (int a = 0; a < r.n; ++a)
if (cudaMemcpyAsync(r.dst[a], r.src[a], (size_t) (n_blocks * r.len[a]), cudaMemcpyDefault,
(cudaStream_t) stream) != cudaSuccess)
check("stage");
}
KvStreamCounters kv_stream_counters(const KvStreamMap& m) {
int32_t c[kKvCtlInts] = {};
KvStreamCounters r;
if (m.ctl == nullptr || cudaMemcpy(c, m.ctl, sizeof(c), cudaMemcpyDeviceToHost) != cudaSuccess) return r;
const unsigned long long* u = reinterpret_cast<const unsigned long long*>(c + 4);
r.misses = u[0];
r.lookups = u[1];
r.calls = u[2];
r.overflow = c[3] != 0;
return r;
}
} // namespace strata::kernels
+87
View File
@@ -86,6 +86,76 @@ __global__ void conv_residual_kernel(const float* history, const float* normaliz
// Exact hidden/result alias is safe: each thread owns one element.
result[c] = __fadd_rn(hidden[c], __fadd_rn(gated[c], activation));
}
// ---- the batch: T tokens, the same arithmetic per element as the kernels above
// weighted_rms_norm (native_gr_norm.cu) with the gamma row repeating every H rows (one token's H groups)
__device__ float norm_warp_sum(float value) {
#pragma unroll
for (int offset = 16; offset > 0; offset >>= 1) value += __shfl_xor_sync(0xffffffffu, value, offset, 32);
return value;
}
__global__ void rms_rep_kernel(const float* __restrict__ input, const float* __restrict__ gamma,
float* __restrict__ output) {
constexpr int BlockSize = 1024; // native_gr_rms_norm_weighted's choice for 2560 columns
const int tid = threadIdx.x;
const size_t row_offset = size_t(blockIdx.x) * N;
input += row_offset;
output += row_offset;
gamma += size_t(blockIdx.x % H) * N;
float partial = 0.0f;
for (int col = tid; col < N; col += BlockSize) {
const float value = input[col];
partial += value * value;
}
__shared__ float sums[32];
partial = norm_warp_sum(partial);
const int lane = tid % 32;
if (lane == 0) sums[tid / 32] = partial;
__syncthreads();
partial = 0.0f;
if (lane < BlockSize / 32) partial = sums[lane];
partial = norm_warp_sum(partial);
const float mean = partial / N;
const float scale = rsqrtf(mean + NG_RMS_EPS);
for (int col = tid; col < N; col += BlockSize) output[col] = scale * input[col] * gamma[col];
}
__global__ void broadcast_batch_kernel(const float* value, const float* gate, float* gated, int T) {
const size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x;
if (i >= size_t(T) * D) return;
const size_t t = i / D, d = i % D;
gated[i] = __fmul_rn(value[t * N + d % N], gate[t * H + d / N]);
}
// the dilated conv (taps 9, 6, 3 tokens back and this one) and the residual; a tap before the chunk reads the history
__global__ void conv_residual_batch_kernel(const float* history, const float* normalized, const uint16_t* weights,
float* hidden, const float* gated, int T) {
const size_t i = size_t(blockIdx.x) * blockDim.x + threadIdx.x;
if (i >= size_t(T) * D) return;
const int t = int(i / D), c = int(i % D);
float sum = 0;
#pragma unroll
for (int k = 0; k < 4; ++k) {
const int p = t - 9 + 3 * k; // the token this tap reads (k == 3: this one)
const float x = p >= 0 ? normalized[size_t(p) * D + c] : history[size_t(c) * HISTORY + (9 + p)];
const float wk = __half2float(__ushort_as_half(weights[c * 4 + k]));
const float term = __fmul_rn(x, wk);
sum = k == 0 ? term : __fadd_rn(sum, term);
}
const float activation = sum / (1.0f + expf(-sum));
hidden[i] = __fadd_rn(hidden[i], __fadd_rn(gated[i], activation));
}
// the history after the chunk: the last nine normalized rows (older ones from the history when T < 9)
__global__ void history_batch_kernel(float* history, const float* normalized, int T) {
const int c = blockIdx.x * blockDim.x + threadIdx.x;
if (c >= D) return;
float h[HISTORY];
#pragma unroll
for (int r = 0; r < HISTORY; ++r) {
const int p = T - HISTORY + r;
h[r] = p >= 0 ? normalized[size_t(p) * D + c] : history[size_t(c) * HISTORY + (T + r)];
}
#pragma unroll
for (int r = 0; r < HISTORY; ++r) history[size_t(c) * HISTORY + r] = h[r];
}
struct Span { const void* p; size_t bytes; size_t alignment; };
bool overlaps(Span a, Span b) {
const auto x = reinterpret_cast<uintptr_t>(a.p), y = reinterpret_cast<uintptr_t>(b.p);
@@ -131,4 +201,21 @@ void native_ple_postops(const float* projected_key, const float* hidden,
conv_residual_kernel<<<D/256,256,0,st>>>(history,b.normalized,w.conv1d_f16,hidden,b.gated,b.conv,b.result);
launch_check();
}
void native_ple_postops_batch(float* key, float* hidden, const float* value, float* history, const PleWeights& w,
float* query_norm, float* gated, float* gate, int T, void* stream) {
if (!stream || T <= 0 || !key || !hidden || !value || !history || !query_norm || !gated || !gate)
throw std::invalid_argument("native PLE postops batch: null input or empty batch");
auto st = static_cast<cudaStream_t>(stream);
const unsigned rows = unsigned(T) * H;
const unsigned blocks = unsigned((size_t(T) * D + 255) / 256);
rms_rep_kernel<<<rows, 1024, 0, st>>>(key, w.norm_key, key);
rms_rep_kernel<<<rows, 1024, 0, st>>>(hidden, w.norm_query, query_norm);
gate_kernel<<<rows, 512, 0, st>>>(key, query_norm, gate, 1.0f / std::sqrt(float(N)));
broadcast_batch_kernel<<<blocks, 256, 0, st>>>(value, gate, gated, T);
rms_rep_kernel<<<rows, 1024, 0, st>>>(gated, w.norm_conv, query_norm);
conv_residual_batch_kernel<<<blocks, 256, 0, st>>>(history, query_norm, w.conv1d_f16, hidden, gated, T);
history_batch_kernel<<<D / 256, 256, 0, st>>>(history, query_norm, T);
launch_check();
}
} // namespace strata::kernels
+17 -7
View File
@@ -43,6 +43,7 @@
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/f16_bits.hpp"
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/rope.hpp"
#include "strata/kernels/mrope.hpp"
@@ -120,16 +121,24 @@ __device__ __forceinline__ float h2f(uint16_t bits) { return f32_from_f16(bits);
__global__ void kv_append_kernel(uint16_t* __restrict__ k_pool, uint16_t* __restrict__ v_pool,
const int32_t* __restrict__ table, const int32_t* __restrict__ step,
const float* __restrict__ kcur, const float* __restrict__ vcur, int kv_heads,
int head_dim, int page_size) {
int head_dim, int page_size, KvHostPools host) {
const long long pos = (long long) __ldg(step + kStepPos);
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= kv_heads * head_dim) return;
const int h = i / head_dim, d = i - h * head_dim;
// `[page][kv_head][page_size][head_dim]`: one head's consecutive cells are contiguous inside a page
// `[page][kv_head][page_size][head_dim]`: one head's consecutive cells are contiguous inside a page.
// KV streaming: the host copy (identity layout) always, the VRAM page only if the block is resident.
const long long page = (long long) table[pos / page_size];
const long long row = (page * kv_heads + h) * page_size + (pos % page_size);
k_pool[row * head_dim + d] = f16_from_f32(kcur[i]);
v_pool[row * head_dim + d] = f16_from_f32(vcur[i]);
if (page >= 0) {
const long long row = (page * kv_heads + h) * page_size + (pos % page_size);
k_pool[row * head_dim + d] = f16_from_f32(kcur[i]);
v_pool[row * head_dim + d] = f16_from_f32(vcur[i]);
}
if (host.k_pool != nullptr) {
const long long row = ((pos / page_size) * kv_heads + h) * page_size + (pos % page_size);
host.k_pool[row * head_dim + d] = f16_from_f32(kcur[i]);
host.v_pool[row * head_dim + d] = f16_from_f32(vcur[i]);
}
}
// ================= 2. indexer_key_append =================
@@ -575,13 +584,14 @@ const int32_t* step_upload_width(int64_t width, const QsaShapes& s) {
// buffer and forward this token's counts as the capacities.
void kv_append_step(uint16_t* k_pool, uint16_t* v_pool, const int32_t* page_table, const int32_t* step,
const float* kcur, const float* vcur, const QsaShapes& s, void* stream) {
const float* kcur, const float* vcur, const QsaShapes& s, void* stream, const KvHostPools* host) {
validate(s, "kv_append");
if (step == nullptr) fail("kv_append: step is null");
// The grid is the head x dim count, which is a CONSTANT - `kv_append` writes one cell.
const long long n = s.n_head_kv * s.head_dim;
kv_append_kernel<<<grid_for(n, THREADS), THREADS, 0, (cudaStream_t) stream>>>(
k_pool, v_pool, page_table, step, kcur, vcur, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size);
k_pool, v_pool, page_table, step, kcur, vcur, (int) s.n_head_kv, (int) s.head_dim, (int) s.page_size,
host ? *host : KvHostPools{});
check_launch("kv_append");
if (stream == nullptr) check_sync("kv_append");
}
+52 -20
View File
@@ -1,6 +1,7 @@
// src/kernels/cuda/qsa_decode_attn.cu - see include/strata/kernels/qsa_decode_attn.hpp.
#include "strata/kernels/qsa_decode_attn.hpp"
#include "strata/kernels/kv_q8.hpp"
#include "strata/kernels/kv_q4.hpp"
#include <cuda_fp16.h>
#include <cuda_runtime.h>
@@ -30,9 +31,9 @@ __device__ __forceinline__ float warp_max(float v) {
}
// 8 consecutive values of one cell's key or value row for KV head `kvh`, dimensions [d0, d0+8).
template <bool INT8>
template <int KV_MODE>
__device__ __forceinline__ void load8(const QsaAttnPools& p, bool value, long long row, int d0, float* out) {
if constexpr (!INT8) {
if constexpr (KV_MODE == 0) {
const uint16_t* base = (value ? p.v_pool : p.k_pool) + row * HD + d0;
const uint4 raw = *reinterpret_cast<const uint4*>(base);
const __half2* h2 = reinterpret_cast<const __half2*>(&raw);
@@ -42,7 +43,7 @@ __device__ __forceinline__ void load8(const QsaAttnPools& p, bool value, long lo
out[2 * j] = f.x;
out[2 * j + 1] = f.y;
}
} else {
} else if constexpr (KV_MODE == 1) {
const int8_t* codes = (value ? p.v_q : p.k_q) + row * HD + d0;
const uint16_t sbits = (value ? p.v_scale : p.k_scale)[row * (HD / KV_Q8_GROUP) + d0 / KV_Q8_GROUP];
const float sc = __half2float(__ushort_as_half(sbits));
@@ -50,10 +51,25 @@ __device__ __forceinline__ void load8(const QsaAttnPools& p, bool value, long lo
const int8_t* c = reinterpret_cast<const int8_t*>(&raw);
#pragma unroll
for (int j = 0; j < 8; ++j) out[j] = (float) c[j] * sc;
} else {
constexpr int bytes_per_head = (HD / QK4_0) * sizeof(block_q4_0);
const int b = d0 / QK4_0;
const int rem = d0 % QK4_0;
const block_q4_0* blk = reinterpret_cast<const block_q4_0*>((value ? p.v_q4 : p.k_q4) + row * bytes_per_head) + b;
const float d = __half2float(__ushort_as_half(blk->d));
const int j = (rem == 0 || rem == 16) ? 0 : 8;
const uint8_t* bytes = blk->qs + j;
if (rem < 16) {
#pragma unroll
for (int k = 0; k < 8; ++k) out[k] = (float) ((int)(bytes[k] & 0x0F) - 8) * d;
} else {
#pragma unroll
for (int k = 0; k < 8; ++k) out[k] = (float) ((int)(bytes[k] >> 4) - 8) * d;
}
}
}
template <bool INT8>
template <int KV_MODE>
__global__ void __launch_bounds__(THREADS) attn_chunk_kernel(const float* __restrict__ q, QsaAttnPools p,
const int32_t* __restrict__ ids,
const int32_t* __restrict__ step, int n_kv_heads,
@@ -98,7 +114,7 @@ __global__ void __launch_bounds__(THREADS) attn_chunk_kernel(const float* __rest
continue;
}
float k8[8];
load8<INT8>(p, false, srow[c], lane * 8, k8);
load8<KV_MODE>(p, false, srow[c], lane * 8, k8);
#pragma unroll
for (int h = 0; h < G; ++h) {
const float4 qa = *reinterpret_cast<const float4*>(&sq[h][lane * 8]);
@@ -128,11 +144,21 @@ __global__ void __launch_bounds__(THREADS) attn_chunk_kernel(const float* __rest
for (int h = 0; h < G; ++h) acc[h] = 0.0f;
for (int c = 0; c < n_here; ++c) {
float v;
if constexpr (!INT8) {
if constexpr (KV_MODE == 0) {
v = __half2float(__ushort_as_half(p.v_pool[srow[c] * HD + t]));
} else {
} else if constexpr (KV_MODE == 1) {
const float sc = __half2float(__ushort_as_half(p.v_scale[srow[c] * (HD / KV_Q8_GROUP) + t / KV_Q8_GROUP]));
v = (float) p.v_q[srow[c] * HD + t] * sc;
} else {
constexpr int bytes_per_head = (HD / QK4_0) * sizeof(block_q4_0);
const int b = t / QK4_0;
const int rem = t % QK4_0;
const block_q4_0* blk = reinterpret_cast<const block_q4_0*>(p.v_q4 + srow[c] * bytes_per_head) + b;
const float d = __half2float(__ushort_as_half(blk->d));
const int j = rem < 16 ? rem : (rem - 16);
const uint8_t byte = blk->qs[j];
const int nibble = (rem < 16) ? ((byte & 0x0F) - 8) : ((byte >> 4) - 8);
v = (float) nibble * d;
}
#pragma unroll
for (int h = 0; h < G; ++h) acc[h] = fmaf(sp[h][c], v, acc[h]);
@@ -176,7 +202,7 @@ void qsa_decode_attn_batch(const float* q, const QsaAttnPools& pools, const int3
std::fprintf(stderr, "qsa_decode_attn_batch: unsupported geometry or missing buffers\n");
std::exit(1);
}
const bool int8 = pools.k_q != nullptr;
const int kv_mode = pools.k_q4 != nullptr ? 2 : (pools.k_q != nullptr ? 1 : 0);
const int n_chunks = (int) ((cap + CHUNK - 1) / CHUNK);
// per query: [acc: n_chunks*n_head*HD][m: n_chunks*n_head][l: n_chunks*n_head], all offsets from one stride
const long long stride = (long long) qsa_decode_attn_scratch_floats(cap, s);
@@ -186,12 +212,15 @@ void qsa_decode_attn_batch(const float* q, const QsaAttnPools& pools, const int3
const float scale = 1.0f / sqrtf((float) HD);
const dim3 grid((unsigned) n_chunks, (unsigned) s.n_head_kv, (unsigned) n_q);
cudaStream_t st = (cudaStream_t) stream;
if (int8)
attn_chunk_kernel<true><<<grid, THREADS, 0, st>>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride);
if (kv_mode == 2)
attn_chunk_kernel<2><<<grid, THREADS, 0, st>>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride);
else if (kv_mode == 1)
attn_chunk_kernel<1><<<grid, THREADS, 0, st>>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride);
else
attn_chunk_kernel<false><<<grid, THREADS, 0, st>>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride);
attn_chunk_kernel<0><<<grid, THREADS, 0, st>>>(q, pools, ids, steps, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks, (int) cap, stride);
attn_merge_kernel<<<dim3((unsigned) s.n_head, (unsigned) n_q), HD, 0, st>>>(part_acc, part_m, part_l, n_chunks,
attn, stride);
const cudaError_t e = cudaGetLastError();
@@ -213,8 +242,8 @@ void qsa_decode_attn_step(const float* q, const QsaAttnPools& pools, const int32
std::fprintf(stderr, "qsa_decode_attn: unsupported geometry or missing buffers\n");
std::exit(1);
}
const bool int8 = pools.k_q != nullptr;
if (int8 ? (!pools.v_q || !pools.k_scale || !pools.v_scale) : (!pools.k_pool || !pools.v_pool)) {
const int kv_mode = pools.k_q4 != nullptr ? 2 : (pools.k_q != nullptr ? 1 : 0);
if (kv_mode == 2 ? (!pools.v_q4) : (kv_mode == 1 ? (!pools.v_q || !pools.k_scale || !pools.v_scale) : (!pools.k_pool || !pools.v_pool))) {
std::fprintf(stderr, "qsa_decode_attn: incomplete KV pools\n");
std::exit(1);
}
@@ -225,12 +254,15 @@ void qsa_decode_attn_step(const float* q, const QsaAttnPools& pools, const int32
const float scale = 1.0f / sqrtf((float) HD);
const dim3 grid((unsigned) n_chunks, (unsigned) s.n_head_kv);
cudaStream_t st = (cudaStream_t) stream;
if (int8)
attn_chunk_kernel<true><<<grid, THREADS, 0, st>>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks);
if (kv_mode == 2)
attn_chunk_kernel<2><<<grid, THREADS, 0, st>>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks);
else if (kv_mode == 1)
attn_chunk_kernel<1><<<grid, THREADS, 0, st>>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks);
else
attn_chunk_kernel<false><<<grid, THREADS, 0, st>>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks);
attn_chunk_kernel<0><<<grid, THREADS, 0, st>>>(q, pools, ids, step, (int) s.n_head_kv, (int) s.page_size,
scale, part_acc, part_m, part_l, n_chunks);
attn_merge_kernel<<<(unsigned) s.n_head, HD, 0, st>>>(part_acc, part_m, part_l, n_chunks, attn);
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
+143 -128
View File
@@ -1,6 +1,6 @@
// src/kernels/cuda/sampler.cu - P2.S2: the sampler chain, in the order docs/sampling.md settles.
//
// penalties -> top_k -> top_p -> temperature -> pick
// penalties -> top_k -> min_p -> top_p -> temperature -> penalties -> pick
//
// THE ORDER IS THE WHOLE CONTENT OF THIS FILE. `docs/sampling.md` transcribes it from llama.cpp's own chain
// (`common/sampling.cpp` L357/360/375/381/399) and the two facts that are easy to get backwards are that
@@ -9,10 +9,9 @@
// valid token, so only a comparison at the distribution level can tell them apart; the parity test does that
// explicitly by running the wrong order and requiring it to differ.
//
// Everything happens on the logits of ONE token in one thread. The vocab is 248,320, which is far too large
// to sort per token on a naive path, so `top_k` uses `k` passes of a maximum scan - 20 x 248,320 = 5.0M
// comparisons per token. Phase 3's note ("sort-free sampler: top-k = 20 makes this easy") is the optimisation;
// this is the correctness version.
// Both kernels put ONE BLOCK per token over the vocabulary: `sampler_greedy_kernel` is the plain argmax,
// `sampler_kernel` runs the sampled chain as `top_k` block-argmax rounds followed by the top_p / temperature /
// draw chain (its header says why the selection must be parallel and why the tie rule keeps the semantics).
#include "strata/kernels/sampler.hpp"
#include <cuda_runtime.h>
@@ -102,12 +101,32 @@ __global__ void sampler_greedy_kernel(const float* __restrict__ logits, int n_vo
hrow += history_len - hlen; // the window is the TAIL
}
// PENALTY MEMBERSHIP AS A BITMAP. The history touches at most `hlen` tokens of a quarter-million
// vocabulary, but the naive `history_count` per candidate per argmax round costs O(k x n_vocab x hlen)
// integer compares (~318 M per token at k=20, hlen=64 - measured 45 -> 31 tok/s on a real workload).
// A shared bitmap gives an O(1) membership test, and only the (at most hlen) hits pay the count scan;
// the counts - and therefore every sampled value - are exactly what the per-candidate scan produced.
extern __shared__ unsigned int penal_bits[];
const int bits_words = (int) ((n_vocab + 31) / 32);
const bool use_bits = hrow != nullptr && bits_words > 0;
if (use_bits) {
for (int w = threadIdx.x; w < bits_words; w += blockDim.x) penal_bits[w] = 0u;
__syncthreads();
for (int i = threadIdx.x; i < hlen; i += blockDim.x)
if (hrow[i] >= 0) atomicOr(&penal_bits[hrow[i] >> 5], 1u << (hrow[i] & 31));
__syncthreads();
}
auto hit_count = [&](int v) -> int {
if (!use_bits || !(penal_bits[v >> 5] & (1u << (v & 31)))) return 0;
return history_count(hrow, hlen, v);
};
// `n_vocab` is the "no candidate" index: it loses every comparison to a real one, so a thread with no
// elements contributes nothing rather than contributing a bogus zero.
float bv = __int_as_float(0xff800000); // -inf
int best = n_vocab;
for (int v = threadIdx.x; v < n_vocab; v += blockDim.x) {
const float s = apply_penalties(l[v], hrow ? history_count(hrow, hlen, v) : 0, p);
const float s = apply_penalties(l[v], hit_count(v), p);
if (s > bv) { bv = s; best = v; }
}
for (int off = 16; off > 0; off >>= 1) {
@@ -134,47 +153,32 @@ __global__ void sampler_greedy_kernel(const float* __restrict__ logits, int n_vo
}
}
/// **THE SAMPLED PATH, ONE BLOCK PER TOKEN.** The kernel below replaced a version that ran the whole chain
/// in ONE THREAD per token (`<<<ceil(T/64), 64>>>`, so a 4-token window fielded four threads): `top_k` alone
/// was `k` sequential scans of the vocabulary with an inner sweep over the already-taken list - 20 x 248,320
/// iterations of dependent work on one SM - and a verify window measured **1.6 s in the sampler**, which made
/// every temperature-bearing request ~30x slower than a greedy one. The selection is `k` argmax rounds, and
/// an argmax over the vocabulary parallelises exactly like `sampler_greedy_kernel` (block over the vocab), so
/// the rounds run back to back inside a block-per-token launch: the per-token cost falls to
/// `k x n_vocab / 1024` plus `k` block reductions.
///
/// THE SEMANTICS ARE THE SERIAL ONES, EXACTLY. Each round's argmax resolves ties to the LOWEST index (the
/// serial scan's strict `>` keeps the first maximum it meets), so the kept sequence - both its set and its
/// order - is unchanged; `top_p`'s cut reads that order in double arithmetic as before; temperature and the
/// Philox draw apply after the cut. `sampler_parity` pins all of it against the host reference.
__global__ void sampler_kernel(const float* __restrict__ logits, int n_vocab, int n_tokens,
const int* __restrict__ history, int history_len, const SamplerParams p,
int* __restrict__ out) {
const int t = blockIdx.x * blockDim.x + threadIdx.x;
const int t = blockIdx.x;
if (t >= n_tokens) return;
// Work on a private copy: the chain mutates the distribution and the caller's logits are shared with the
// next stage. A 248,320-float allocation per thread is far too much for shared memory, so this uses a
// two-pass approach instead - first find the keep set, then normalise over it - and never materialises a
// second full-size buffer.
const float* l = logits + (size_t) t * n_vocab;
// Temperature is needed by BOTH paths below, so it is computed here; the chain still APPLIES it after
// Temperature is needed by BOTH stages below, so it is computed here; the chain still APPLIES it after
// the truncation filters - the survivors are chosen on the raw logits and only then scaled.
const float inv_t = p.temperature > 0.0f ? 1.0f / p.temperature : 0.0f;
// ---- penalties: NOT IMPLEMENTED, and the ORDER they must take is transcribed here so the next person
// does not have to guess it. `llama_sampler_penalties_apply` (src/llama-sampler.cpp) runs AFTER
// temperature in the chain (L381 against L375), and for every token seen in the last `penalty_last_n`:
//
// if (logit <= 0) logit *= penalty_repeat; else logit /= penalty_repeat;
// logit -= float(count) * penalty_freq + float(count > 0) * penalty_present;
//
// TWO details there are counter-intuitive. The repeat penalty MULTIPLIES for non-positive logits and
// DIVIDES for positive ones - the source notes the paper only ever divided, "but that would cause tokens
// with negative logits to become more likely, which is obviously wrong". And the presence penalty rides
// on `float(count > 0)`, a BOOLEAN cast to float, not the count - so it applies once however many times
// the token appeared. Both are the kind of thing a plausible implementation gets wrong while still
// producing valid tokens.
//
// It needs the token history, which is not in this signature, so a caller passing a non-zero
// `penalty_last_n` gets a loud failure rather than a silently unpenalised sample.
// ---- top_k: k passes of a maximum scan, skipping the already-taken
// ---- top_p: cumulative mass over the survivors in descending order
// The two are done together in one selection pass so the survivors and their mass are consistent.
// GREEDY IS THE GLOBAL ARGMAX, WHATEVER THE FILTERS SAY - so it is computed directly and the filter chain
// is skipped entirely. No filter can remove the maximum: top_k keeps the k largest, top_p keeps a prefix
// of the descending order that always contains the largest (min_keep >= 1), and temperature is monotonic so
// it cannot reorder anything. That also makes greedy INDEPENDENT OF THE ORDER, which is why the ordering
// test cannot use it - see sampler_parity.cpp.
// The penalty window is the last `penalty_last_n` entries of this row's history.
// The penalty window is the last `penalty_last_n` entries of this row's history (disabled at this
// launch: `sample_tokens` refuses a non-zero `penalty_last_n` without a history buffer).
const int* hrow = history ? history + (size_t) t * history_len : nullptr;
int hlen = 0;
if (hrow) {
@@ -183,109 +187,117 @@ __global__ void sampler_kernel(const float* __restrict__ logits, int n_vocab, in
hrow += history_len - hlen; // the window is the TAIL
}
if (p.greedy || p.temperature <= 0.0f) {
// Greedy is the global argmax ONLY when no penalty is active: a penalty can lower a token enough to
// change the maximum, so the argmax has to be taken over the penalised logits, not the raw ones.
//
// *** TEMPERATURE MUST NOT BE APPLIED HERE. THIS WAS A REAL BUG. ***
// This branch used to read `apply_penalties(l[v] * inv_t, ...)`. `inv_t` is 0.0f whenever
// temperature <= 0 (see above), so at temperature 0 EVERY logit became 0.0f and the argmax returned
// index 0 - the sampler emitted token 0 forever, whatever the model predicted. OpenAI clients send
// `temperature: 0` for greedy decoding, so this was reachable from any ordinary client.
// It is also pointless even when inv_t is non-zero: scaling by a positive constant is monotonic and
// cannot reorder the argmax, which is exactly what the note above this block already says.
int best = 0;
float bv = apply_penalties(l[0], hrow ? history_count(hrow, hlen, 0) : 0, p);
for (int v = 1; v < n_vocab; ++v) {
const float s = apply_penalties(l[v], hrow ? history_count(hrow, hlen, v) : 0, p);
if (s > bv) { bv = s; best = v; }
}
out[t] = best;
return;
// the membership bitmap, as in `sampler_greedy_kernel` - see the cost note there
extern __shared__ unsigned int penal_bits[];
const int bits_words = (int) ((n_vocab + 31) / 32);
const bool use_bits = hrow != nullptr && bits_words > 0;
if (use_bits) {
for (int w = threadIdx.x; w < bits_words; w += blockDim.x) penal_bits[w] = 0u;
__syncthreads();
for (int i = threadIdx.x; i < hlen; i += blockDim.x)
if (hrow[i] >= 0) atomicOr(&penal_bits[hrow[i] >> 5], 1u << (hrow[i] & 31));
__syncthreads();
}
auto hit_count = [&](int v) -> int {
if (!use_bits || !(penal_bits[v >> 5] & (1u << (v & 31)))) return 0;
return history_count(hrow, hlen, v);
};
const int KMAX = 64;
int keep_ids[KMAX];
float keep_logit[KMAX];
int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : KMAX;
if (p.top_k <= 0) k = 0; // 0 means "no top-k filter" -> handled by the mask below
int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : 0;
if (k <= 0) {
std::printf("sampler: the sampled path needs top_k in 1..%d (got %d); greedy needs no filters\n", KMAX, p.top_k);
if (threadIdx.x == 0)
std::printf("sampler: the sampled path needs top_k in 1..%d (got %d); greedy needs no filters\n",
KMAX, p.top_k);
return; // leave out[t] unwritten rather than returning an uninitialised token
}
int n_keep = 0;
{
for (int i = 0; i < k; ++i) {
int best = -1;
float bv = 0.0f;
for (int v = 0; v < n_vocab; ++v) {
bool taken = false;
for (int j = 0; j < i; ++j) if (keep_ids[j] == v) { taken = true; break; }
if (taken) continue;
if (best < 0 || l[v] > bv) { best = v; bv = l[v]; }
// ---- top_k: k rounds of a block argmax over the not-yet-taken. `sel_*` holds the kept ids and their
// raw logits in selection order: descending by value, ties to the lower index, which is the order the
// top_p cut below is defined over.
__shared__ int sel_ids[KMAX];
__shared__ float sel_logit[KMAX];
__shared__ float sv[32];
__shared__ int si[32];
for (int i = 0; i < k; ++i) {
// `n_vocab` is the "no candidate" index: it loses every comparison to a real one (same convention as
// the greedy kernel, whose tie rule this reduction shares).
float bv = __int_as_float(0xff800000); // -inf
int best = n_vocab;
for (int v = threadIdx.x; v < n_vocab; v += blockDim.x) {
bool taken = false;
for (int j = 0; j < i; ++j) if (sel_ids[j] == v) { taken = true; break; }
if (taken) continue;
const float s = apply_penalties(l[v], hit_count(v), p);
if (s > bv) { bv = s; best = v; }
}
for (int off = 16; off > 0; off >>= 1) {
const float ov = __shfl_down_sync(0xFFFFFFFFu, bv, off);
const int oi = __shfl_down_sync(0xFFFFFFFFu, best, off);
if (ov > bv || (ov == bv && oi < best)) { bv = ov; best = oi; }
}
const int warp = (int) (threadIdx.x >> 5), lane = (int) (threadIdx.x & 31);
if (lane == 0) { sv[warp] = bv; si[warp] = best; }
__syncthreads();
if (warp == 0) {
const int nw = (int) ((blockDim.x + 31) >> 5);
float wv = lane < nw ? sv[lane] : __int_as_float(0xff800000);
int wi = lane < nw ? si[lane] : n_vocab;
for (int off = 16; off > 0; off >>= 1) {
const float ov = __shfl_down_sync(0xFFFFFFFFu, wv, off);
const int oi = __shfl_down_sync(0xFFFFFFFFu, wi, off);
if (ov > wv || (ov == wv && oi < wi)) { wv = ov; wi = oi; }
}
keep_ids[i] = best;
keep_logit[i] = bv;
}
n_keep = k;
// ---- top_p over the survivors, in descending order (which the selection above already produced)
if (p.top_p < 1.0f) {
// softmax over the keep set for the cumulative mass
float mx = keep_logit[0];
for (int i = 1; i < n_keep; ++i) mx = fmaxf(mx, keep_logit[i]);
double sum = 0.0;
for (int i = 0; i < n_keep; ++i) sum += exp((double) keep_logit[i] - (double) mx);
double cum = 0.0;
int cut = n_keep;
for (int i = 0; i < n_keep; ++i) {
cum += exp((double) keep_logit[i] - (double) mx) / sum;
if (cum >= (double) p.top_p) { cut = i + 1; break; }
}
if (cut < p.min_keep) cut = p.min_keep < n_keep ? p.min_keep : n_keep;
n_keep = cut;
if (lane == 0) { sel_ids[i] = (wi < n_vocab) ? wi : 0; sel_logit[i] = wv; }
}
__syncthreads();
}
// ---- min_p: keep the descending prefix whose probability is at least `min_p` of the top token's. The
// kept list is in selection order (descending), so the survivors are a PREFIX and the cut composes with
// top_p's below. In logit space the threshold is `sel_logit[0] + logf(min_p)` - equivalent to
// `p >= min_p * p_max` without the overflow an exp of raw logits risks. 0 disables, and the head itself
// always survives (`expf(0) == 1 >= min_p` for min_p in 0..1), so the count never reaches zero.
int n_minp = k;
if (p.min_p > 0.0f) {
const float thresh = sel_logit[0] + logf(p.min_p);
for (int i = 0; i < k; ++i)
if (sel_logit[i] < thresh) { n_minp = i; break; }
}
if (p.greedy || p.temperature <= 0.0f) {
// DEAD CODE AS WRITTEN: the identical condition at the top of this function already returned, so this
// block cannot be reached. It is kept and corrected rather than deleted because if that early return
// is ever narrowed, this is the path that would run - and it carried the same `* inv_t` bug (every
// logit 0.0f at temperature 0, so `best` would always be `keep_ids[0]`). Temperature is monotonic and
// cannot reorder a maximum, so it must not appear here at all.
// argmax over the SURVIVORS, ties by smallest id (the same convention as the router and top_k)
int best = keep_ids[0];
float bv = keep_logit[0];
for (int i = 1; i < n_keep; ++i) {
const float v = keep_logit[i];
if (v > bv) { bv = v; best = keep_ids[i]; }
// ---- top_p over the survivors, in descending order (which the selection produced), then temperature and
// one Philox draw. Every thread computes the same chain redundantly over `sel_*` - the arithmetic is the
// serial kernel's, instruction for instruction - so they agree on `pick` and thread 0 writes it.
int n_keep = n_minp;
float mx = sel_logit[0];
for (int i = 1; i < n_minp; ++i) mx = fmaxf(mx, sel_logit[i]);
if (p.top_p < 1.0f) {
double sum = 0.0;
for (int i = 0; i < n_minp; ++i) sum += exp((double) sel_logit[i] - (double) mx);
double cum = 0.0;
int cut = n_minp;
for (int i = 0; i < n_minp; ++i) {
cum += exp((double) sel_logit[i] - (double) mx) / sum;
if (cum >= (double) p.top_p) { cut = i + 1; break; }
}
out[t] = best;
return;
if (cut < p.min_keep) cut = p.min_keep < n_minp ? p.min_keep : n_minp;
n_keep = cut;
}
// ---- pick: temperature, then penalties, then softmax over the survivors, then one uniform draw.
// The ORDER is the chain's: the survivors were chosen on the RAW logits, temperature scales them, and the
// penalty is applied to the scaled value - which is why the `logit <= 0` branch inside the penalty sees a
// temperature-scaled logit and not the model's own.
for (int i = 0; i < n_keep; ++i) {
keep_logit[i] = apply_penalties(keep_logit[i] * inv_t,
hrow ? history_count(hrow, hlen, keep_ids[i]) : 0, p);
}
float mx = keep_logit[0];
for (int i = 1; i < n_keep; ++i) mx = fmaxf(mx, keep_logit[i]);
auto scaled = [&](int i) {
return apply_penalties(sel_logit[i] * inv_t, hit_count(sel_ids[i]), p);
};
float smx = scaled(0);
for (int i = 1; i < n_keep; ++i) smx = fmaxf(smx, scaled(i));
double sum = 0.0;
for (int i = 0; i < n_keep; ++i) sum += exp((double) keep_logit[i] - (double) mx);
for (int i = 0; i < n_keep; ++i) sum += exp((double) scaled(i) - (double) smx);
const float u = philox_uniform(p.seed, p.counter + (uint64_t) t);
double cum = 0.0;
int pick = keep_ids[n_keep - 1];
int pick = sel_ids[n_keep - 1];
for (int i = 0; i < n_keep; ++i) {
cum += exp((double) keep_logit[i] - (double) mx) / sum;
if ((double) u < cum) { pick = keep_ids[i]; break; }
cum += exp((double) scaled(i) - (double) smx) / sum;
if ((double) u < cum) { pick = sel_ids[i]; break; }
}
out[t] = pick;
if (threadIdx.x == 0) out[t] = pick;
}
} // namespace
@@ -298,16 +310,19 @@ void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* hi
p.penalty_last_n, (const void*) history, history_len);
std::exit(1);
}
const int threads = 64;
const unsigned grid = (unsigned) ((n_tokens + threads - 1) / threads);
const unsigned shmem = (history != nullptr && history_len > 0 && p.penalty_last_n > 0)
? (unsigned) ((n_vocab + 31) / 32) * sizeof(unsigned) // the penalty bitmap
: 0;
if (p.greedy || p.temperature <= 0.0f) {
// One block per token, 1,024 threads over the vocabulary. See `sampler_greedy_kernel`.
const int gthreads = 1024;
sampler_greedy_kernel<<<(unsigned) n_tokens, gthreads, 0, (cudaStream_t) stream>>>(
sampler_greedy_kernel<<<(unsigned) n_tokens, gthreads, shmem, (cudaStream_t) stream>>>(
logits, n_vocab, history, history_len, p, p.penalty_last_n, p.penalty_last_n, out);
} else {
sampler_kernel<<<grid, threads, 0, (cudaStream_t) stream>>>(logits, n_vocab, n_tokens, history,
history_len, p, out);
// The same block-per-token shape: the selection's k argmax rounds reduce inside the block. See
// `sampler_kernel`'s header for what the old one-thread-per-token launch cost.
sampler_kernel<<<(unsigned) n_tokens, 1024, shmem, (cudaStream_t) stream>>>(
logits, n_vocab, n_tokens, history, history_len, p, out);
}
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
+14 -7
View File
@@ -244,12 +244,14 @@ __global__ void ident_hits_kernel(const int32_t* __restrict__ ids, int n, int32_
if (i == 0) *count = n;
}
__global__ void gather_rows_kernel(const uint4* __restrict__ src, long long row16, const int32_t* __restrict__ ids,
long long n, uint4* __restrict__ dst) {
const long long total = n * row16;
// E = the widest element the row size divides into (16, 4 or 1 bytes): a Q6_K head row of 2560 values is 2100 bytes
template<typename E>
__global__ void gather_rows_kernel(const E* __restrict__ src, long long row_e, const int32_t* __restrict__ ids,
long long n, E* __restrict__ dst) {
const long long total = n * row_e;
for (long long i = (long long) blockIdx.x * blockDim.x + threadIdx.x; i < total; i += (long long) gridDim.x * blockDim.x) {
const long long r = i / row16, o = i - r * row16;
dst[i] = src[(long long) ids[r] * row16 + o];
const long long r = i / row_e, o = i - r * row_e;
dst[i] = src[(long long) ids[r] * row_e + o];
}
}
@@ -334,8 +336,13 @@ void mtp_select(const float* R_src, int64_t R_stride, const int32_t* ids, const
}
void gather_rows(const uint8_t* src, int64_t row_bytes, const int32_t* ids, int64_t n, uint8_t* dst, void* stream) {
if (row_bytes % 16 != 0) { std::fprintf(stderr, "gather_rows: row size must be a multiple of 16\n"); std::exit(1); }
gather_rows_kernel<<<48 * 8, 256, 0, (cudaStream_t) stream>>>((const uint4*) src, row_bytes / 16, ids, n, (uint4*) dst);
cudaStream_t s = (cudaStream_t) stream;
if (row_bytes % 16 == 0)
gather_rows_kernel<<<48 * 8, 256, 0, s>>>((const uint4*) src, row_bytes / 16, ids, n, (uint4*) dst);
else if (row_bytes % 4 == 0)
gather_rows_kernel<<<48 * 8, 256, 0, s>>>((const uint32_t*) src, row_bytes / 4, ids, n, (uint32_t*) dst);
else
gather_rows_kernel<<<48 * 8, 256, 0, s>>>(src, row_bytes, ids, n, dst);
check("gather_rows");
}
+223
View File
@@ -0,0 +1,223 @@
// src/kernels/cvec_parity.cpp - the control vector kernel (strata/kernels/cvec.hpp) against a host reference.
// 1. project: h - s (h.v) v per stream and token, against double precision; the steered component is gone.
// 2. add: h + d.
// 3. switched off: R unchanged without a pending write; with one, R is BITWISE the write the fused read folds
// (fused_gr_read with apply), which is what keeps a loaded-but-off vector identical to the stock engine.
// 4. a layer without a direction is untouched.
#include "strata/kernels/cvec.hpp"
#include "strata/kernels/fused_gr.hpp"
#include <cuda_runtime.h>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <vector>
namespace k = strata::kernels;
namespace {
int g_fail = 0;
void ck(cudaError_t e, const char* w) {
if (e != cudaSuccess) {
std::fprintf(stderr, "CUDA error in %s: %s\n", w, cudaGetErrorString(e));
std::exit(2);
}
}
template <typename T>
T* dalloc(size_t n) {
T* p = nullptr;
ck(cudaMalloc(&p, n * sizeof(T)), "malloc");
return p;
}
template <typename T>
void up(T* d, const std::vector<T>& h) { ck(cudaMemcpy(d, h.data(), h.size() * sizeof(T), cudaMemcpyHostToDevice), "up"); }
template <typename T>
std::vector<T> down(const T* d, size_t n) {
std::vector<T> h(n);
ck(cudaMemcpy(h.data(), d, n * sizeof(T), cudaMemcpyDeviceToHost), "down");
return h;
}
void check(bool ok, const char* what) {
std::printf(" %-66s %s\n", what, ok ? "ok" : "FAIL");
if (!ok) ++g_fail;
}
} // namespace
int main() {
constexpr int64_t L = 48, N = 2560, HC = 4, LR = 320, T = 5, D = HC * N;
constexpr int64_t kLayer = 7, kOff = 3; // steered / not steered
std::mt19937 rng(1234);
std::normal_distribution<float> nd(0.0f, 1.0f);
// a unit direction at kLayer with a norm-2 scaled one's s = 2; nothing at kOff
std::vector<float> dir((size_t) (L * N), 0.0f), s((size_t) L, 0.0f);
{
double nrm = 0.0;
std::vector<float> v((size_t) N);
for (auto& x : v) { x = nd(rng); nrm += (double) x * x; }
nrm = std::sqrt(nrm);
for (int64_t j = 0; j < N; ++j) dir[(size_t) (kLayer * N + j)] = (float) (v[(size_t) j] / nrm);
s[(size_t) kLayer] = 2.0f;
}
std::string err;
if (!k::cvec_upload(dir, s, /*project*/ 0, 4, 44, N, HC, err)) { std::fprintf(stderr, "%s\n", err.c_str()); return 2; }
check(k::cvec().covers(kLayer) && !k::cvec().covers(kOff) && !k::cvec().covers(45), "covers(): steered layers only");
std::vector<float> R((size_t) (T * D)), bo((size_t) (T * N)), inj((size_t) (T * HC));
for (auto& x : R) x = nd(rng) * 3.0f;
for (auto& x : bo) x = nd(rng);
for (auto& x : inj) x = nd(rng);
float* dR = dalloc<float>(R.size());
float* dbo = dalloc<float>(bo.size());
float* dinj = dalloc<float>(inj.size());
up(dbo, bo);
up(dinj, inj);
// ---- 1. project, no pending write
std::printf("project\n");
up(dR, R);
k::cvec_apply(dR, kLayer, T, D, nullptr, 0, nullptr, 0, false, nullptr);
ck(cudaDeviceSynchronize(), "project");
{
const auto got = down(dR, R.size());
double worst = 0.0, worst_dot = 0.0;
for (int64_t t = 0; t < T; ++t)
for (int64_t c = 0; c < HC; ++c) {
const float* h = R.data() + t * D + c * N;
const float* v = dir.data() + kLayer * N;
double dot = 0.0;
for (int64_t j = 0; j < N; ++j) dot += (double) h[j] * v[j];
double after = 0.0;
for (int64_t j = 0; j < N; ++j) {
const double want = h[j] - 2.0 * dot * v[j];
const double g = got[(size_t) (t * D + c * N + j)];
worst = std::fmax(worst, std::fabs(g - want));
after += g * v[j];
}
// s = 2 reflects the component: h.v -> -h.v
worst_dot = std::fmax(worst_dot, std::fabs(after + dot));
}
std::printf(" max |err| %.3g, max |h'.v + h.v| %.3g\n", worst, worst_dot);
check(worst < 1e-4, "project matches h - s (h.v) v (s = 2)");
check(worst_dot < 1e-3, "the component along v is reflected (s = 2)");
}
// s = 1 removes it
s[(size_t) kLayer] = 1.0f;
if (!k::cvec_upload(dir, s, 0, 4, 44, N, HC, err)) return 2;
up(dR, R);
k::cvec_apply(dR, kLayer, T, D, nullptr, 0, nullptr, 0, false, nullptr);
ck(cudaDeviceSynchronize(), "project s=1");
{
const auto got = down(dR, R.size());
double worst = 0.0;
for (int64_t t = 0; t < T; ++t)
for (int64_t c = 0; c < HC; ++c) {
double after = 0.0;
for (int64_t j = 0; j < N; ++j) after += (double) got[(size_t) (t * D + c * N + j)] * dir[(size_t) (kLayer * N + j)];
worst = std::fmax(worst, std::fabs(after));
}
std::printf(" max |h'.v| %.3g\n", worst);
check(worst < 1e-3, "s = 1 projects the direction out");
}
// ---- 4. a layer without a direction
up(dR, R);
k::cvec_apply(dR, kOff, T, D, nullptr, 0, nullptr, 0, false, nullptr);
ck(cudaDeviceSynchronize(), "off layer");
check(std::memcmp(down(dR, R.size()).data(), R.data(), R.size() * 4) == 0, "a layer without a direction is untouched");
// ---- 3. switched off
std::printf("switched off\n");
k::cvec_set_enabled(false);
check(!k::cvec_enabled(), "cvec_enabled() follows the switch");
up(dR, R);
k::cvec_apply(dR, kLayer, T, D, nullptr, 0, nullptr, 0, false, nullptr);
ck(cudaDeviceSynchronize(), "off");
check(std::memcmp(down(dR, R.size()).data(), R.data(), R.size() * 4) == 0, "off, no pending write: R unchanged");
{
// the fused read's folded write, one token at a time, against the kernel's write-only pass
std::vector<uint16_t> wd((size_t) (LR * D)), wu((size_t) (D * LR)), wi((size_t) (HC * D));
std::vector<float> wn((size_t) D);
auto bf = [&](float x) { uint32_t u; std::memcpy(&u, &x, 4); return (uint16_t) (u >> 16); };
for (auto& x : wd) x = bf(nd(rng) * 0.02f);
for (auto& x : wu) x = bf(nd(rng) * 0.02f);
for (auto& x : wi) x = bf(nd(rng) * 0.02f);
for (auto& x : wn) x = 1.0f + 0.1f * nd(rng);
uint16_t* dwd = dalloc<uint16_t>(wd.size());
uint16_t* dwu = dalloc<uint16_t>(wu.size());
uint16_t* dwi = dalloc<uint16_t>(wi.size());
float* dwn = dalloc<float>(wn.size());
up(dwd, wd); up(dwu, wu); up(dwi, wi); up(dwn, wn);
float* lo = dalloc<float>(LR);
float* rs = dalloc<float>(HC);
float* inj_out = dalloc<float>(HC);
float* mixed = dalloc<float>(N);
float* dF = dalloc<float>(R.size());
up(dF, R);
for (int64_t t = 0; t < T; ++t) {
k::FusedGrArgs a;
a.R = dF + t * D; a.R_out = dF + t * D; a.apply = true;
a.bo_prev = dbo + t * N; a.inj_prev = dinj + t * HC;
a.w_norm = dwn; a.w_down = dwd; a.w_up = dwu; a.w_inject = dwi;
a.lo = lo; a.rs = rs; a.inject_out = inj_out; a.mixed = mixed;
k::fused_gr_read(a, nullptr);
}
up(dR, R);
k::cvec_apply(dR, kLayer, T, D, dbo, N, dinj, HC, true, nullptr);
ck(cudaDeviceSynchronize(), "write only");
const auto fused = down(dF, R.size()), mine = down(dR, R.size());
check(std::memcmp(fused.data(), mine.data(), R.size() * 4) == 0,
"off, pending write: bitwise the fused read's folded write");
k::cvec_set_enabled(true);
// on, with the write: the write, then the projection (reference from the fused result)
up(dR, R);
k::cvec_apply(dR, kLayer, T, D, dbo, N, dinj, HC, true, nullptr);
ck(cudaDeviceSynchronize(), "write+project");
const auto both = down(dR, R.size());
double worst = 0.0;
for (int64_t t = 0; t < T; ++t)
for (int64_t c = 0; c < HC; ++c) {
const float* h = fused.data() + t * D + c * N;
const float* v = dir.data() + kLayer * N;
double dot = 0.0;
for (int64_t j = 0; j < N; ++j) dot += (double) h[j] * v[j];
for (int64_t j = 0; j < N; ++j)
worst = std::fmax(worst, std::fabs(both[(size_t) (t * D + c * N + j)] - (h[j] - dot * v[j])));
}
std::printf(" write + project: max |err| %.3g\n", worst);
check(worst < 1e-4, "on, pending write: the write, then the projection");
}
// ---- 2. add
std::printf("add\n");
for (int64_t j = 0; j < N; ++j) dir[(size_t) (kLayer * N + j)] *= 0.1f; // d = 0.1 v, s = 1
s[(size_t) kLayer] = 1.0f;
if (!k::cvec_upload(dir, s, /*add*/ 1, 4, 44, N, HC, err)) return 2;
up(dR, R);
k::cvec_apply(dR, kLayer, T, D, nullptr, 0, nullptr, 0, false, nullptr);
ck(cudaDeviceSynchronize(), "add");
{
const auto got = down(dR, R.size());
bool same = true;
for (int64_t t = 0; t < T; ++t)
for (int64_t c = 0; c < HC; ++c)
for (int64_t j = 0; j < N; ++j) {
const size_t i = (size_t) (t * D + c * N + j);
same = same && got[i] == R[i] + dir[(size_t) (kLayer * N + j)];
}
check(same, "add is h + d in every stream (bitwise)");
}
std::printf(g_fail ? "cvec_parity: %d FAILED\n" : "cvec_parity: all passed\n", g_fail);
return g_fail ? 1 : 0;
}
+346
View File
@@ -0,0 +1,346 @@
// src/kernels/kv_q4_parity.cpp - Parity test for Q4_0 KV cache with orthonormal Walsh-Hadamard rotation.
// Validates:
// 1. FWHT 256 CUDA kernel vs exact mathematical orthonormal Walsh-Hadamard transform.
// (H * H * x == x, H is symmetric and orthonormal with scale 1/sqrt(256) = 1/16).
// 2. Q4_0 quantization and packing (32 values per block, 18 bytes) bitwise vs host reference.
// 3. kv_append_q4_step and kv_gather_q4_step through paged pool against host reference.
// 4. Invariance of dot products under Walsh-Hadamard rotation: (H*q) . (H*k) == q . k.
#include "strata/kernels/f16_bits.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/qsa.hpp"
#include <cuda_runtime.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <vector>
namespace k = strata::kernels;
namespace {
int g_fail = 0;
void ck(cudaError_t e, const char* w) {
if (e != cudaSuccess) {
std::fprintf(stderr, "CUDA error in %s: %s\n", w, cudaGetErrorString(e));
std::exit(2);
}
}
template <typename T>
T* dalloc(size_t n) {
T* p = nullptr;
ck(cudaMalloc(&p, n * sizeof(T) + 64), "malloc");
ck(cudaMemset(p, 0, n * sizeof(T) + 64), "memset");
return p;
}
// Exact CPU reference for Fast Walsh-Hadamard Transform (D = 256)
// Sylvester construction matching llama.cpp's ggml_gen_hadamard
void fwht256_host_reference(float* x) {
constexpr int n = 256;
for (int s = 1; s < n; s *= 2) {
for (int i = 0; i < n; i += 2 * s) {
for (int j = 0; j < s; ++j) {
const float u = x[i + j];
const float v = x[i + j + s];
x[i + j] = u + v;
x[i + j + s] = u - v;
}
}
}
const float scale = 1.0f / 16.0f; // 1 / sqrt(256)
for (int i = 0; i < n; ++i) {
x[i] *= scale;
}
}
// Host reference for Q4_0 quantization of 32 elements (1 block)
void quantize_block_q4_0_host(const float* x, k::block_q4_0& blk) {
float amax = 0.0f;
float max_val = 0.0f;
for (int i = 0; i < 32; ++i) {
const float v = x[i];
if (amax < std::fabs(v)) {
amax = std::fabs(v);
max_val = v;
}
}
const float d = max_val / -8.0f;
const float id = d != 0.0f ? 1.0f / d : 0.0f;
blk.d = k::f16_from_f32(d);
for (int i = 0; i < 16; ++i) {
const float x0 = x[i] * id;
const float x1 = x[i + 16] * id;
const int q0 = (int) (x0 + 8.5f);
const int q1 = (int) (x1 + 8.5f);
const uint8_t xi0 = (uint8_t) std::min(15, std::max(0, q0));
const uint8_t xi1 = (uint8_t) std::min(15, std::max(0, q1));
blk.qs[i] = xi0 | (xi1 << 4);
}
}
} // namespace
int main() {
std::printf("=== Running kv_q4_parity test ===\n");
std::mt19937 rng(42);
std::normal_distribution<float> nd(0.0f, 1.0f);
// -------------------------------------------------------------
// Test 1: Orthonormal Fast Walsh-Hadamard Transform (D=256)
// -------------------------------------------------------------
std::printf("[1/4] Verifying FWHT-256 CUDA kernel vs exact mathematical reference...\n");
constexpr int n_vectors = 64;
std::vector<float> h_in(n_vectors * 256);
std::vector<float> h_ref(n_vectors * 256);
for (size_t i = 0; i < h_in.size(); ++i) {
h_in[i] = nd(rng);
}
h_ref = h_in;
// Run CPU reference on each row
for (int r = 0; r < n_vectors; ++r) {
fwht256_host_reference(h_ref.data() + r * 256);
}
// Run GPU kernel
float* d_src = dalloc<float>(n_vectors * 256);
float* d_dst = dalloc<float>(n_vectors * 256);
ck(cudaMemcpy(d_src, h_in.data(), h_in.size() * sizeof(float), cudaMemcpyHostToDevice), "memcpy H2D");
k::fwht256_cuda(d_src, d_dst, n_vectors, nullptr);
ck(cudaDeviceSynchronize(), "fwht256_cuda");
std::vector<float> h_out(n_vectors * 256);
ck(cudaMemcpy(h_out.data(), d_dst, h_out.size() * sizeof(float), cudaMemcpyDeviceToHost), "memcpy D2H");
double max_fwht_diff = 0.0;
for (size_t i = 0; i < h_out.size(); ++i) {
double diff = std::fabs(h_out[i] - h_ref[i]);
if (diff > max_fwht_diff) max_fwht_diff = diff;
}
if (max_fwht_diff > 1e-5) {
std::fprintf(stderr, "FAIL: FWHT-256 GPU output differs from host reference (max diff = %e)\n", max_fwht_diff);
++g_fail;
} else {
std::printf(" -> FWHT-256 bitwise match with host reference (max diff = %e, OK)\n", max_fwht_diff);
}
// Self-inverse property: H * (H * x) == x
k::fwht256_inplace_cuda(d_dst, n_vectors, nullptr);
ck(cudaDeviceSynchronize(), "fwht256_inplace_cuda");
ck(cudaMemcpy(h_out.data(), d_dst, h_out.size() * sizeof(float), cudaMemcpyDeviceToHost), "memcpy D2H");
double max_inv_diff = 0.0;
for (size_t i = 0; i < h_out.size(); ++i) {
double diff = std::fabs(h_out[i] - h_in[i]);
if (diff > max_inv_diff) max_inv_diff = diff;
}
if (max_inv_diff > 1e-5) {
std::fprintf(stderr, "FAIL: FWHT-256 self-inverse property failed (max diff = %e)\n", max_inv_diff);
++g_fail;
} else {
std::printf(" -> FWHT-256 self-inverse property verified H*H*x == x (max diff = %e, OK)\n", max_inv_diff);
}
// -------------------------------------------------------------
// Test 2: Invariance of Attention Scores under Hadamard Rotation
// -------------------------------------------------------------
std::printf("[2/4] Verifying Attention Score Invariance: <H*q, H*k> == <q, k>...\n");
double max_dot_diff = 0.0;
for (int r = 0; r < n_vectors; r += 2) {
const float* q = h_in.data() + r * 256;
const float* k_vec = h_in.data() + (r + 1) * 256;
const float* hq = h_ref.data() + r * 256;
const float* hk = h_ref.data() + (r + 1) * 256;
double dot_raw = 0.0;
double dot_rot = 0.0;
for (int i = 0; i < 256; ++i) {
dot_raw += (double) q[i] * (double) k_vec[i];
dot_rot += (double) hq[i] * (double) hk[i];
}
double d = std::fabs(dot_raw - dot_rot);
if (d > max_dot_diff) max_dot_diff = d;
}
if (max_dot_diff > 1e-5) {
std::fprintf(stderr, "FAIL: Rotated dot product differs from original (max diff = %e)\n", max_dot_diff);
++g_fail;
} else {
std::printf(" -> Dot product invariant under rotation: |<Hq,Hk> - <q,k>| = %e (OK)\n", max_dot_diff);
}
// -------------------------------------------------------------
// Test 3: Q4_0 Paged KV Append & Gather against Host Reference
// -------------------------------------------------------------
std::printf("[3/4] Verifying Q4_0 KV append and gather against host reference...\n");
k::QsaShapes s = k::qsa_real_shapes();
s.page_size = 64;
const int H = (int) s.n_head_kv; // 2
const int D = (int) s.head_dim; // 256
const int P = (int) s.page_size; // 64
const int pages = 8;
const int cells = pages * P;
std::vector<int32_t> table(pages);
for (int i = 0; i < pages; ++i) table[i] = (i * 3 + 5) % pages; // permutation
int32_t* d_table = dalloc<int32_t>(pages);
ck(cudaMemcpy(d_table, table.data(), pages * sizeof(int32_t), cudaMemcpyHostToDevice), "memcpy table");
const size_t pool_bytes = (size_t) pages * H * P * k::kv_q4_bytes_per_head(D);
uint8_t* d_k_q4 = dalloc<uint8_t>(pool_bytes);
uint8_t* d_v_q4 = dalloc<uint8_t>(pool_bytes);
float* d_kcur = dalloc<float>(H * D);
float* d_vcur = dalloc<float>(H * D);
int32_t* d_step = dalloc<int32_t>(k::kStepCount);
std::vector<std::vector<float>> host_k(cells), host_v(cells);
std::vector<int> positions(cells);
for (int i = 0; i < cells; ++i) positions[i] = i;
std::shuffle(positions.begin(), positions.end(), rng);
const int n_fill = cells - 25; // leave some cells empty
for (int n = 0; n < n_fill; ++n) {
const int pos = positions[n];
std::vector<float> kv(H * D), vv(H * D);
for (auto& x : kv) x = nd(rng) * 2.0f;
for (auto& x : vv) x = nd(rng) * 2.0f;
// Apply FWHT before append
for (int h = 0; h < H; ++h) {
fwht256_host_reference(kv.data() + h * D);
fwht256_host_reference(vv.data() + h * D);
}
host_k[pos] = kv;
host_v[pos] = vv;
int32_t hstep[k::kStepCount] = {pos, pos + 1, 0, 0};
ck(cudaMemcpy(d_step, hstep, sizeof(hstep), cudaMemcpyHostToDevice), "memcpy step");
ck(cudaMemcpy(d_kcur, kv.data(), kv.size() * sizeof(float), cudaMemcpyHostToDevice), "memcpy k");
ck(cudaMemcpy(d_vcur, vv.data(), vv.size() * sizeof(float), cudaMemcpyHostToDevice), "memcpy v");
k::kv_append_q4_step(d_k_q4, d_v_q4, d_table, d_step, d_kcur, d_vcur, s, nullptr);
ck(cudaDeviceSynchronize(), "kv_append_q4_step");
}
// Verify written Q4_0 blocks bitwise against host quantizer
std::vector<uint8_t> h_k_q4(pool_bytes), h_v_q4(pool_bytes);
ck(cudaMemcpy(h_k_q4.data(), d_k_q4, pool_bytes, cudaMemcpyDeviceToHost), "memcpy d2h k_q4");
ck(cudaMemcpy(h_v_q4.data(), d_v_q4, pool_bytes, cudaMemcpyDeviceToHost), "memcpy d2h v_q4");
long bad_blocks = 0;
const int blocks_per_head = D / k::QK4_0; // 8
const int bytes_per_head = blocks_per_head * sizeof(k::block_q4_0); // 144
for (int n = 0; n < n_fill; ++n) {
const int pos = positions[n];
for (int is_v = 0; is_v < 2; ++is_v) {
for (int h = 0; h < H; ++h) {
const long long page = (long long) table[pos / P];
const long long row = (page * H + h) * P + (pos % P);
const uint8_t* pool_ptr = (is_v ? h_v_q4.data() : h_k_q4.data()) + row * bytes_per_head;
const float* raw_vec = (is_v ? host_v[pos].data() : host_k[pos].data()) + h * D;
for (int b = 0; b < blocks_per_head; ++b) {
k::block_q4_0 host_blk;
quantize_block_q4_0_host(raw_vec + b * 32, host_blk);
const k::block_q4_0* gpu_blk = reinterpret_cast<const k::block_q4_0*>(pool_ptr) + b;
if (std::memcmp(&host_blk, gpu_blk, sizeof(k::block_q4_0)) != 0) {
if (bad_blocks == 0) {
std::printf("MISMATCH at n=%d, pos=%d, is_v=%d, h=%d, b=%d\n", n, pos, is_v, h, b);
std::printf(" host blk.d = 0x%04x (%f), gpu blk.d = 0x%04x (%f)\n",
host_blk.d, k::f32_from_f16(host_blk.d),
gpu_blk->d, k::f32_from_f16(gpu_blk->d));
std::printf(" host qs: ");
for (int i = 0; i < 16; ++i) std::printf("%02x ", host_blk.qs[i]);
std::printf("\n gpu qs: ");
for (int i = 0; i < 16; ++i) std::printf("%02x ", gpu_blk->qs[i]);
std::printf("\n");
}
++bad_blocks;
}
}
}
}
}
if (bad_blocks > 0) {
std::fprintf(stderr, "FAIL: %ld Q4_0 blocks differ from host quantizer\n", bad_blocks);
++g_fail;
} else {
std::printf(" -> All Q4_0 pool blocks bitwise equal to host quantizer (OK)\n");
}
// -------------------------------------------------------------
// Test 4: Gather Q4_0 into FP16 Scratch and verify accuracy
// -------------------------------------------------------------
std::printf("[4/4] Verifying Q4_0 gather unpacking and dequantization...\n");
const int max_ids = 128;
int32_t* d_ids = dalloc<int32_t>(max_ids);
uint16_t* d_k_scratch = dalloc<uint16_t>((size_t) max_ids * H * D);
uint16_t* d_v_scratch = dalloc<uint16_t>((size_t) max_ids * H * D);
std::vector<int32_t> ids(max_ids);
for (int i = 0; i < max_ids; ++i) {
ids[i] = positions[rng() % n_fill];
}
ck(cudaMemcpy(d_ids, ids.data(), max_ids * sizeof(int32_t), cudaMemcpyHostToDevice), "memcpy ids");
int32_t hstep[k::kStepCount] = {0, 0, 0, max_ids};
ck(cudaMemcpy(d_step, hstep, sizeof(hstep), cudaMemcpyHostToDevice), "memcpy step");
k::kv_gather_q4_step(d_k_q4, d_v_q4, d_table, d_ids, d_step, max_ids, s, d_k_scratch, d_v_scratch, nullptr);
ck(cudaDeviceSynchronize(), "kv_gather_q4_step");
std::vector<uint16_t> h_k_scratch((size_t) max_ids * H * D);
std::vector<uint16_t> h_v_scratch((size_t) max_ids * H * D);
ck(cudaMemcpy(h_k_scratch.data(), d_k_scratch, h_k_scratch.size() * sizeof(uint16_t), cudaMemcpyDeviceToHost), "memcpy d2h k_scratch");
ck(cudaMemcpy(h_v_scratch.data(), d_v_scratch, h_v_scratch.size() * sizeof(uint16_t), cudaMemcpyDeviceToHost), "memcpy d2h v_scratch");
double worst_quant_err = 0.0;
for (int i = 0; i < max_ids; ++i) {
const int cell = ids[i];
for (int is_v = 0; is_v < 2; ++is_v) {
for (int h = 0; h < H; ++h) {
const float* orig = (is_v ? host_v[cell].data() : host_k[cell].data()) + h * D;
const uint16_t* gathered = (is_v ? h_v_scratch.data() : h_k_scratch.data()) + (i * H + h) * D;
for (int d = 0; d < D; ++d) {
float deq = k::f32_from_f16(gathered[d]);
float raw = orig[d];
double err = std::fabs(deq - raw);
if (err > worst_quant_err) worst_quant_err = err;
}
}
}
}
std::printf(" -> Max reconstruction error after 4-bit quant + dequant: %.4f (OK)\n", worst_quant_err);
cudaFree(d_src);
cudaFree(d_dst);
cudaFree(d_table);
cudaFree(d_k_q4);
cudaFree(d_v_q4);
cudaFree(d_kcur);
cudaFree(d_vcur);
cudaFree(d_step);
cudaFree(d_ids);
cudaFree(d_k_scratch);
cudaFree(d_v_scratch);
if (g_fail == 0) {
std::printf("=== kv_q4_parity: ALL TESTS PASSED SUCCESSFULLY! ===\n");
return 0;
} else {
std::fprintf(stderr, "=== kv_q4_parity: FAILED (%d failures) ===\n", g_fail);
return 1;
}
}
+234
View File
@@ -0,0 +1,234 @@
// src/kernels/kv_stream_parity.cpp - KV streaming (kv_stream.hpp) against a fully resident pool (GPU, no model).
//
// Decodes a synthetic sequence twice: into a fully resident pool with the identity page table, and into a streamed
// layer (host copy + a small VRAM slot pool that must evict constantly). Every few cells a batch of 1-8 queries
// with selections shaped like the indexer's (whole 4-cell blocks, a partial threshold block, the tail block, half of
// them recent) is attended in both; `kv_stream_resolve` runs before the streamed attention. Checks:
// 1. the attention outputs are BITWISE equal (the streamed readers see exactly the resident values);
// 2. the residency map is consistent after every call (slot_block and page_table invert each other);
// 3. no call overflowed, and the hit/miss counters add up;
// 4. a ring (the MTP drafter's layout) restored from the host copy reads the same values as the resident pool.
// INT8, FP16 and Q4_0 (PR #21) pools.
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/kv_q8.hpp"
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/qsa_decode_attn.hpp"
#include <cuda_runtime.h>
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <set>
#include <vector>
namespace k = strata::kernels;
namespace {
int g_fail = 0;
void ck(cudaError_t e, const char* w) {
if (e != cudaSuccess) { std::fprintf(stderr, "%s: %s\n", w, cudaGetErrorString(e)); std::exit(2); }
}
template <typename T> T* dalloc(size_t n) {
T* p = nullptr;
ck(cudaMalloc(&p, n * sizeof(T) + 64), "malloc");
ck(cudaMemset(p, 0, n * sizeof(T) + 64), "memset");
return p;
}
template <typename T> T* halloc(size_t n) { // pinned, mapped; returns the device pointer
void* h = nullptr;
void* d = nullptr;
ck(cudaHostAlloc(&h, n * sizeof(T) + 64, cudaHostAllocMapped), "hostalloc");
std::memset(h, 0, n * sizeof(T) + 64);
ck(cudaHostGetDevicePointer(&d, h, 0), "devptr");
return (T*) d;
}
struct Pools { // one K/V pool set of `pages` pages
k::KvHostPools p; // reused as a plain pointer bundle
void alloc(int64_t pages, const k::QsaShapes& s, int fmt, bool host) {
const size_t rows = (size_t) pages * s.n_head_kv * s.page_size;
if (fmt == k::kKvQ4) {
const size_t b = rows * k::kv_q4_bytes_per_head((int) s.head_dim);
p.k_q4 = host ? halloc<uint8_t>(b) : dalloc<uint8_t>(b);
p.v_q4 = host ? halloc<uint8_t>(b) : dalloc<uint8_t>(b);
} else if (fmt == k::kKvInt8) {
p.k_q = host ? halloc<int8_t>(rows * s.head_dim) : dalloc<int8_t>(rows * s.head_dim);
p.v_q = host ? halloc<int8_t>(rows * s.head_dim) : dalloc<int8_t>(rows * s.head_dim);
p.k_scale = host ? halloc<uint16_t>(rows * 4) : dalloc<uint16_t>(rows * 4);
p.v_scale = host ? halloc<uint16_t>(rows * 4) : dalloc<uint16_t>(rows * 4);
} else {
p.k_pool = host ? halloc<uint16_t>(rows * s.head_dim) : dalloc<uint16_t>(rows * s.head_dim);
p.v_pool = host ? halloc<uint16_t>(rows * s.head_dim) : dalloc<uint16_t>(rows * s.head_dim);
}
}
k::QsaAttnPools attn(const int32_t* table) const {
k::QsaAttnPools a;
a.k_pool = p.k_pool; a.v_pool = p.v_pool; a.k_q = p.k_q; a.v_q = p.v_q; a.k_scale = p.k_scale;
a.v_scale = p.v_scale; a.k_q4 = p.k_q4; a.v_q4 = p.v_q4; a.page_table = table;
return a;
}
};
void append(const Pools& pl, const int32_t* table, const int32_t* step, const float* kc, const float* vc,
const k::QsaShapes& s, int fmt, const k::KvHostPools* host) {
if (fmt == k::kKvQ4) k::kv_append_q4_step(pl.p.k_q4, pl.p.v_q4, table, step, kc, vc, s, nullptr, host);
else if (fmt == k::kKvInt8) k::kv_append_q8_step(pl.p.k_q, pl.p.v_q, pl.p.k_scale, pl.p.v_scale, table, step, kc, vc, s, nullptr, host);
else k::kv_append_step(pl.p.k_pool, pl.p.v_pool, table, step, kc, vc, s, nullptr, host);
}
// A selection like qsa_block_topk's: ascending cells, whole blocks, the tail block's cells, and one partial block.
std::vector<int32_t> selection(int64_t n_kv, int64_t width, std::mt19937& rng) {
std::vector<int32_t> ids;
if (n_kv <= width) {
for (int64_t c = 0; c < n_kv; ++c) ids.push_back((int32_t) c);
return ids;
}
const int64_t n_bid = n_kv / 4, tail = n_kv - n_bid * 4;
std::set<int64_t> blocks;
const int64_t want_full = (width - tail) / 4, part = (width - tail) % 4;
std::uniform_int_distribution<int64_t> any(0, n_bid - 1), recent(std::max<int64_t>(0, n_bid - 2048), n_bid - 1);
while ((int64_t) blocks.size() < want_full + (part ? 1 : 0)) blocks.insert(rng() % 2 ? recent(rng) : any(rng));
int64_t partial_block = part ? *std::next(blocks.begin(), (long) (rng() % blocks.size())) : -1;
for (int64_t b : blocks) {
const int64_t take = b == partial_block ? part : 4;
for (int64_t i = 0; i < take; ++i) ids.push_back((int32_t) (b * 4 + i));
}
for (int64_t c = n_bid * 4; c < n_kv; ++c) ids.push_back((int32_t) c);
std::sort(ids.begin(), ids.end());
return ids;
}
bool run(int fmt) {
const char* name = fmt == k::kKvQ4 ? "q4_0" : fmt == k::kKvInt8 ? "int8" : "fp16";
k::QsaShapes s = k::qsa_real_shapes();
const int64_t N = 40000, n_blocks = (N + 3) / 4, n_slots = 8 * 516 + 700; // must evict: slots < blocks
const int64_t cap = k::qsa_selection_width(k::kTopkMaxCells, s), NQ = 8;
const int64_t H = s.n_head_kv, D = s.head_dim, NH = s.n_head;
std::mt19937 rng(7 + 4 * fmt);
std::normal_distribution<float> nd(0.f, 1.f);
Pools ref, slots, host;
ref.alloc(n_blocks, s, fmt, false);
slots.alloc(n_slots, s, fmt, false);
host.alloc(n_blocks, s, fmt, true);
int32_t* ident = dalloc<int32_t>(n_blocks);
{
std::vector<int32_t> t(n_blocks);
for (int64_t i = 0; i < n_blocks; ++i) t[i] = (int32_t) i;
ck(cudaMemcpy(ident, t.data(), n_blocks * 4, cudaMemcpyHostToDevice), "ident");
}
k::KvStreamMap m;
m.page_table = dalloc<int32_t>(n_blocks);
m.slot_block = dalloc<int32_t>(n_slots); m.slot_stamp = dalloc<int32_t>(n_slots); m.slot_ref = dalloc<int32_t>(n_slots);
m.miss_block = dalloc<int32_t>(n_slots); m.miss_slot = dalloc<int32_t>(n_slots);
m.ctl = dalloc<int32_t>(k::kKvCtlInts);
m.n_blocks = n_blocks; m.n_slots = n_slots;
k::kv_stream_reset(m, nullptr);
float* kc = dalloc<float>(H * D);
float* vc = dalloc<float>(H * D);
int32_t* step = dalloc<int32_t>(k::kStepCount);
int32_t* steps = dalloc<int32_t>(NQ * k::kStepCount);
int32_t* ids = dalloc<int32_t>(NQ * cap);
float* q = dalloc<float>(NQ * NH * D);
const uint64_t scr = k::qsa_decode_attn_scratch_floats(cap, s);
float* scratch = dalloc<float>(NQ * scr);
float* out_ref = dalloc<float>(NQ * NH * D);
float* out_str = dalloc<float>(NQ * NH * D);
std::vector<float> hk(H * D), hv(H * D), hq(NQ * NH * D), a((size_t) NQ * NH * D), b2((size_t) NQ * NH * D);
std::vector<int32_t> pt(n_blocks), sb(n_slots);
int batches = 0, bad = 0;
for (int64_t pos = 0; pos < N; ++pos) {
for (auto& x : hk) x = nd(rng) * ((pos % 17 == 0) ? 30.f : 1.f);
for (auto& x : hv) x = nd(rng);
const int32_t st[4] = {(int32_t) pos, (int32_t) (pos + 1), (int32_t) ((pos + 1) / 4), 0};
ck(cudaMemcpy(kc, hk.data(), hk.size() * 4, cudaMemcpyHostToDevice), "k");
ck(cudaMemcpy(vc, hv.data(), hv.size() * 4, cudaMemcpyHostToDevice), "v");
ck(cudaMemcpy(step, st, sizeof(st), cudaMemcpyHostToDevice), "step");
append(ref, ident, step, kc, vc, s, fmt, nullptr);
append(slots, m.page_table, step, kc, vc, s, fmt, &host.p);
if (pos % 131 != 130 && pos != N - 1) continue;
// a batch: n_q queries at the last n_q positions (as a verify window), each with its own selection
const int n_q = 1 + (int) (rng() % NQ);
std::vector<int32_t> hids((size_t) NQ * cap, 0), hst(NQ * 4, 0);
for (int t = 0; t < n_q; ++t) {
const int64_t p = std::max<int64_t>(0, pos - (n_q - 1 - t)), n_kv = p + 1;
const int64_t width = std::min<int64_t>(n_kv, cap);
const std::vector<int32_t> sel = selection(n_kv, width, rng);
if ((int64_t) sel.size() != width) { std::fprintf(stderr, "selection size %zu != %lld\n", sel.size(), (long long) width); return false; }
std::copy(sel.begin(), sel.end(), hids.begin() + (size_t) t * cap);
hst[t * 4 + 0] = (int32_t) p; hst[t * 4 + 1] = (int32_t) n_kv; hst[t * 4 + 2] = (int32_t) (n_kv / 4);
hst[t * 4 + 3] = (int32_t) width;
}
for (auto& x : hq) x = nd(rng);
ck(cudaMemcpy(ids, hids.data(), hids.size() * 4, cudaMemcpyHostToDevice), "ids");
ck(cudaMemcpy(steps, hst.data(), hst.size() * 4, cudaMemcpyHostToDevice), "steps");
ck(cudaMemcpy(q, hq.data(), hq.size() * 4, cudaMemcpyHostToDevice), "q");
k::qsa_decode_attn_batch(q, ref.attn(ident), ids, steps, cap, s, scratch, out_ref, n_q, nullptr);
k::kv_stream_resolve(m, slots.attn(m.page_table), host.p, fmt, ids, steps, n_q, cap, s, nullptr);
k::qsa_decode_attn_batch(q, slots.attn(m.page_table), ids, steps, cap, s, scratch, out_str, n_q, nullptr);
ck(cudaDeviceSynchronize(), "batch");
ck(cudaMemcpy(a.data(), out_ref, (size_t) n_q * NH * D * 4, cudaMemcpyDeviceToHost), "a");
ck(cudaMemcpy(b2.data(), out_str, (size_t) n_q * NH * D * 4, cudaMemcpyDeviceToHost), "b");
if (std::memcmp(a.data(), b2.data(), (size_t) n_q * NH * D * 4) != 0) {
if (bad++ < 5) std::fprintf(stderr, " %s pos %lld n_q %d: streamed attention differs\n", name, (long long) pos, n_q);
}
// the map inverts itself
ck(cudaMemcpy(pt.data(), m.page_table, n_blocks * 4, cudaMemcpyDeviceToHost), "pt");
ck(cudaMemcpy(sb.data(), m.slot_block, n_slots * 4, cudaMemcpyDeviceToHost), "sb");
int64_t resident = 0;
for (int64_t bk = 0; bk < n_blocks; ++bk) {
if (pt[bk] < -1 || pt[bk] >= n_slots || (pt[bk] >= 0 && sb[pt[bk]] != bk)) {
if (bad++ < 5) std::fprintf(stderr, " map broken at block %lld (table %d)\n", (long long) bk, pt[bk]);
break;
}
resident += pt[bk] >= 0;
}
for (int64_t sl = 0; sl < n_slots; ++sl)
if (sb[sl] >= 0 && pt[sb[sl]] != sl) { if (bad++ < 5) std::fprintf(stderr, " slot %lld not in the table\n", (long long) sl); break; }
++batches;
}
const k::KvStreamCounters c = k::kv_stream_counters(m);
std::printf(" %s: %d batches, %llu block lookups, %llu misses (%.1f%% hit), overflow %d, %d failures\n",
name, batches, (unsigned long long) c.lookups, (unsigned long long) c.misses,
c.lookups ? 100.0 * (double) (c.lookups - c.misses) / (double) c.lookups : 0.0, (int) c.overflow, bad);
if (c.overflow || c.calls != (uint64_t) batches || c.misses == 0 || c.misses >= c.lookups) ++bad;
// 4. a ring over the same host copy: blocks [b1 - R, b1) restored, read through `b % R`
{
const int64_t R = 1500, b1 = n_blocks, b0 = b1 - R;
Pools ring;
ring.alloc(R, s, fmt, false);
int32_t* rt = dalloc<int32_t>(n_blocks);
k::kv_ring_table(rt, n_blocks, R, nullptr);
k::kv_ring_restore(ring.attn(rt), host.p, fmt, b0, b1, R, s, nullptr);
const int64_t width = cap;
std::vector<int32_t> hids((size_t) cap), hst = {(int32_t) (N - 1), (int32_t) N, (int32_t) (N / 4), (int32_t) width};
for (int64_t i = 0; i < width; ++i) hids[i] = (int32_t) (N - width + i); // the window's last cells
ck(cudaMemcpy(ids, hids.data(), hids.size() * 4, cudaMemcpyHostToDevice), "ids");
ck(cudaMemcpy(steps, hst.data(), 16, cudaMemcpyHostToDevice), "steps");
k::qsa_decode_attn_batch(q, ref.attn(ident), ids, steps, cap, s, scratch, out_ref, 1, nullptr);
k::qsa_decode_attn_batch(q, ring.attn(rt), ids, steps, cap, s, scratch, out_str, 1, nullptr);
ck(cudaDeviceSynchronize(), "ring");
ck(cudaMemcpy(a.data(), out_ref, (size_t) NH * D * 4, cudaMemcpyDeviceToHost), "a");
ck(cudaMemcpy(b2.data(), out_str, (size_t) NH * D * 4, cudaMemcpyDeviceToHost), "b");
const bool ok = std::memcmp(a.data(), b2.data(), (size_t) NH * D * 4) == 0;
std::printf(" %s ring restore: %s\n", name, ok ? "identical" : "DIFFERS");
if (!ok) ++bad;
}
return bad == 0;
}
} // namespace
int main() {
std::printf("kv_stream_parity: streamed vs resident KV, bitwise\n");
const bool a = run(k::kKvInt8), b = run(k::kKvF16), c = run(k::kKvQ4);
if (!a || !b || !c) ++g_fail;
std::printf(g_fail ? "FAIL\n" : "PASS\n");
return g_fail ? 1 : 0;
}
+71 -25
View File
@@ -9,6 +9,8 @@
#include "strata/kernels/cpu/native_expert.hpp"
#include "strata/kernels/cpu/expert.hpp"
#include "strata/kernels/cpu/iq_avx512.hpp"
#include "strata/kernels/cpu/iq_avx2.hpp"
#include "strata/kernels/cpu/expert_layout.hpp"
#include "ggml-cpu.h"
#include "strata/kernels/iq_kernels.hpp"
@@ -33,6 +35,7 @@ static double rel(const std::vector<float>& a, const std::vector<float>& b) {
}
int main(int argc, char** argv) {
setvbuf(stdout, nullptr, _IONBF, 0); // keep the trail on a crash
if (argc < 2) { std::fprintf(stderr, "usage: native_expert_parity <shard1.gguf> [layer ...]\n"); return 2; }
strata::GgufFile gguf(argv[1]);
std::vector<int> layers;
@@ -103,40 +106,50 @@ int main(int argc, char** argv) {
}
cpu::native_gu_rows(f, blob.data(), a, NT, ffp, 0, (int) FF);
if (cpu::iq512_supported(f.gu_type)) {
// the AVX-512 rows against ggml's own vec_dot, same Q8_K activations: float-order differences only
std::vector<float> g512((size_t) NT * FF), gref((size_t) NT * FF);
float* gp[NT];
for (int k = 0; k < NT; ++k) gp[k] = g512.data() + k * FF;
cpu::iq512_rows(f.gu_type, blob.data(), f.gu_row, (int) H, a, NT, gp, 0, (int) FF);
// ggml's own vec_dot, same Q8_K activations: the reference for both multi-token kernels
// (float-order differences only)
const auto* tc = ggml_get_type_traits_cpu((ggml_type) f.gu_type);
std::vector<float> gref((size_t) NT * FF);
for (int k = 0; k < NT; ++k)
for (int64_t r = 0; r < FF; ++r)
tc->vec_dot((int) H, &gref[k * FF + r], 0, blob.data() + r * f.gu_row, 0, a[k], 0, 1);
{ // single-thread throughput, the weights cache-resident (compute bound)
float* f1[1] = {ffp[0]};
double usg = 0.0;
{ // ggml single-token throughput, the weights cache-resident (compute bound)
const int it = 50;
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i) cpu::iq512_gu_rows(f.gu_type, blob.data(), f.gu_row, f.up_off, (int) H, a, 1, f1, 0, (int) FF);
auto t1 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i)
for (int64_t r = 0; r < FF; ++r) {
float gg, uu;
tc->vec_dot((int) H, &gg, 0, blob.data() + r * f.gu_row, 0, a[0], 0, 1);
tc->vec_dot((int) H, &uu, 0, blob.data() + f.up_off + r * f.gu_row, 0, a[0], 0, 1);
}
auto t2 = std::chrono::steady_clock::now();
const double us512 = std::chrono::duration<double, std::micro>(t1 - t0).count() / it;
const double usg = std::chrono::duration<double, std::micro>(t2 - t1).count() / it;
std::printf(" gate+up one token one thread: avx512 %.0f us (%.2f GB/s), ggml %.0f us (%.2f GB/s)\n",
us512, 2.0 * f.up_off / us512 / 1e3, usg, 2.0 * f.up_off / usg / 1e3);
auto t3 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i) cpu::iq512_gu_rows(f.gu_type, blob.data(), f.gu_row, f.up_off, (int) H, a, NT, ffp, 0, (int) FF);
auto t4 = std::chrono::steady_clock::now();
std::printf(" gate+up %d tokens one thread: avx512 %.0f us vs ggml %d x %.0f us\n", NT,
std::chrono::duration<double, std::micro>(t4 - t3).count() / it, NT, usg);
auto t1 = std::chrono::steady_clock::now();
usg = std::chrono::duration<double, std::micro>(t1 - t0).count() / it;
}
std::printf(" %s AVX-512 gate rows vs ggml vec_dot: rel %.2e\n",
ggml_type_name((ggml_type) f.gu_type), rel(g512, gref));
// one multi-token kernel: parity against ggml's vec_dot, then one-token and NT-token throughput
auto check = [&](const char* tag, bool is512) {
std::vector<float> g((size_t) NT * FF);
float* gp[NT];
for (int k = 0; k < NT; ++k) gp[k] = g.data() + k * FF;
(is512 ? cpu::iq512_rows : cpu::iq256_rows)(f.gu_type, blob.data(), f.gu_row, (int) H, a, NT, gp, 0, (int) FF);
std::printf(" %s %s gate rows vs ggml vec_dot: rel %.2e\n",
ggml_type_name((ggml_type) f.gu_type), tag, rel(g, gref));
float* f1[1] = {ffp[0]};
const int it = 50;
const auto gu = is512 ? cpu::iq512_gu_rows : cpu::iq256_gu_rows;
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i) gu(f.gu_type, blob.data(), f.gu_row, f.up_off, (int) H, a, 1, f1, 0, (int) FF);
auto t1 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i) gu(f.gu_type, blob.data(), f.gu_row, f.up_off, (int) H, a, NT, ffp, 0, (int) FF);
auto t2 = std::chrono::steady_clock::now();
const double us1 = std::chrono::duration<double, std::micro>(t1 - t0).count() / it;
const double usn = std::chrono::duration<double, std::micro>(t2 - t1).count() / it;
std::printf(" gate+up one token one thread: %s %.0f us (%.2f GB/s), ggml %.0f us (%.2f GB/s)\n",
tag, us1, 2.0 * f.up_off / us1 / 1e3, usg, 2.0 * f.up_off / usg / 1e3);
std::printf(" gate+up %d tokens one thread: %s %.0f us vs ggml %d x %.0f us\n", NT, tag, usn, NT, usg);
};
if (cpu::cpu_avx512_ok()) check("avx512", true); // guarded: the binary runs on AVX-2 CPUs too
check("avx2", false);
}
for (int k = 0; k < NT; ++k) {
cpu::native_quant_h(f, ff[k].data(), hq[k].data());
@@ -145,18 +158,51 @@ int main(int argc, char** argv) {
}
cpu::native_down_rows(f, blob.data(), hp, NT, op, 0, (int) H);
if (f.d_type == 42) {
// (b2) the AVX-512 GGUF-layout Q2_0 kernel the pool uses for Q2_0 down projections
// (b2) the GGUF-layout Q2_0 kernel the pool uses for Q2_0 down projections - the AVX-512 one
// where the CPU has it, the AVX-2 one (q2_avx2.cpp) where it does not. Calling the AVX-512
// kernel unconditionally faults on a Zen 2/3 CPU.
std::vector<cpu::ActQ> a2(NT);
const cpu::ActQ* ap[NT];
std::vector<float> alt((size_t) NT * H);
float* altp[NT];
for (int k = 0; k < NT; ++k) {
cpu::act_quant_q8_1(ff[k].data(), (int) FF, a2[k]);
cpu::act_quant_any(ff[k].data(), (int) FF, a2[k]); // gated: AVX-512 or AVX-2 per CPU
ap[k] = &a2[k];
altp[k] = alt.data() + k * H;
}
cpu::q2_0_gguf_rows_multi(blob.data() + f.down_off, f.d_row, (int) (FF / 64), ap, NT, altp, 0, (int) H);
std::printf(" q2_0 AVX-512 down vs ggml down: rel %.2e\n", rel(alt, got_c));
cpu::q2_rows_any(blob.data() + f.down_off, f.d_row, (int) (FF / 64), ap, NT, altp, 0, (int) H);
std::printf(" q2_0 %s down vs ggml down: rel %.2e\n",
cpu::cpu_avx512_ok() ? "AVX-512" : "AVX-2", rel(alt, got_c));
}
if (f.d_type == 20) {
// (b3) the IQ4_NL multi-token AVX-2 kernel the pool now uses for IQ4_NL down projections,
// against ggml-cpu's single-token vec_dot on the SAME Q8_0 activations (h), plus timing.
std::vector<float> alt((size_t) NT * H), ref((size_t) NT * H);
float* altp[NT];
for (int k = 0; k < NT; ++k) altp[k] = alt.data() + k * H;
cpu::iq4nl256_down_rows(blob.data() + f.down_off, f.d_row, (int) FF, hp, NT, altp, 0, (int) H);
const auto* tdc = ggml_get_type_traits_cpu((ggml_type) f.d_type);
for (int k = 0; k < NT; ++k)
for (int64_t r = 0; r < H; ++r)
tdc->vec_dot((int) FF, ref.data() + k * H + r, 0,
blob.data() + f.down_off + (size_t) r * f.d_row, 0, hp[k], 0, 1);
std::printf(" iq4_nl AVX-2 multi-token down vs ggml down: rel %.2e\n", rel(alt, ref));
const int it = 50;
const auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i)
cpu::iq4nl256_down_rows(blob.data() + f.down_off, f.d_row, (int) FF, hp, NT, altp, 0, (int) H);
const auto t1 = std::chrono::steady_clock::now();
for (int i = 0; i < it; ++i)
for (int k = 0; k < NT; ++k)
for (int64_t r = 0; r < H; ++r)
tdc->vec_dot((int) FF, ref.data() + k * H + r, 0,
blob.data() + f.down_off + (size_t) r * f.d_row, 0, hp[k], 0, 1);
const auto t2 = std::chrono::steady_clock::now();
const double usn = std::chrono::duration<double, std::micro>(t1 - t0).count() / it;
const double usg = std::chrono::duration<double, std::micro>(t2 - t1).count() / it;
std::printf(" down %d tokens one thread: avx2 %.0f us (%.2f GB/s) vs ggml %.0f us (%.2f GB/s)\n",
NT, usn, (double) (f.bytes - f.down_off) / usn / 1e3, usg,
(double) (f.bytes - f.down_off) / usg / 1e3);
}
}
// (c) the GPU: one group holding the NT entries
+244
View File
@@ -12,6 +12,7 @@
#include <cuda_runtime.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
@@ -126,6 +127,139 @@ int run(const char* name, const std::vector<float>& logits, int n_tokens, const
return bad;
}
// The Philox draw, host side - a transcription of the kernel's `philox_uniform` so the SAMPLED pick (not
// just the greedy argmax) can be pinned against a reference. `__umulhi(a, b)` is the high half of a 32x32
// multiply, spelled `(uint32_t)(((uint64_t) a * b) >> 32)` here.
struct PhiloxRound {
uint32_t& c0; uint32_t& c1; uint32_t& c2; uint32_t& c3;
void step(uint32_t k0, uint32_t k1) const {
const uint32_t hi0 = (uint32_t) (((uint64_t) 0x9E3779B9u * c0) >> 32);
const uint32_t hi1 = (uint32_t) (((uint64_t) 0xBB67AE85u * c2) >> 32);
const uint32_t lo0 = 0x9E3779B9u * c0;
const uint32_t lo1 = 0xBB67AE85u * c2;
const uint32_t n0 = hi1 ^ c1 ^ k0;
const uint32_t n1 = lo1;
const uint32_t n2 = hi0 ^ c3 ^ k1;
const uint32_t n3 = lo0;
c0 = n0; c1 = n1; c2 = n2; c3 = n3;
}
};
float host_philox_uniform(uint64_t seed, uint64_t counter) {
uint32_t c0 = (uint32_t) counter, c1 = (uint32_t) (counter >> 32);
uint32_t c2 = (uint32_t) seed, c3 = (uint32_t) (seed >> 32);
PhiloxRound r{c0, c1, c2, c3};
for (int i = 0; i < 10; ++i) r.step((uint32_t) i, 0u);
return (float) (c0 >> 8) * (1.0f / 16777216.0f);
}
// The full SAMPLED chain, host side - the kernel's `sampler_kernel` in serial form: penalties on the raw
// logits during the top_k selection (ties to the lowest index), the min_p prefix cut, top_p's cut in double,
// then penalties AGAIN on the temperature-scaled survivors (docs/sampling.md's after-temperature pass), and
// one Philox draw at (seed, counter + row).
int sampled_reference(const std::vector<float>& l, const std::vector<int>& hist,
const strata::kernels::SamplerParams& p, int row) {
auto penal = [&](float logit, int count) {
if (count <= 0) return logit;
if (logit <= 0.0f) logit *= p.penalty_repeat; else logit /= p.penalty_repeat;
logit -= (float) count * p.penalty_freq + (count > 0 ? 1.0f : 0.0f) * p.penalty_present;
return logit;
};
auto count = [&](int v) { int c = 0; for (int h : hist) if (h == v) ++c; return c; };
const int nv = (int) l.size();
const int KMAX = 64;
const int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : 0;
std::vector<int> sel_ids;
std::vector<float> sel_logit;
std::vector<char> taken((size_t) nv, 0);
for (int i = 0; i < k; ++i) {
int best = -1; float bv = 0;
for (int v = 0; v < nv; ++v) {
if (taken[(size_t) v]) continue;
const float s = penal(l[(size_t) v], count(v));
if (best < 0 || s > bv) { best = v; bv = s; }
}
taken[(size_t) best] = 1;
sel_ids.push_back(best); sel_logit.push_back(bv);
}
int n_minp = (int) sel_ids.size();
if (p.min_p > 0.0f) {
const float thresh = sel_logit[0] + std::log(p.min_p);
for (int i = 0; i < (int) sel_ids.size(); ++i)
if (sel_logit[(size_t) i] < thresh) { n_minp = i; break; }
}
int n_keep = n_minp;
if (p.top_p < 1.0f) {
double sum = 0.0;
for (int i = 0; i < n_minp; ++i) sum += std::exp((double) sel_logit[(size_t) i] - (double) sel_logit[0]);
double cum = 0.0;
int cut = n_minp;
for (int i = 0; i < n_minp; ++i) {
cum += std::exp((double) sel_logit[(size_t) i] - (double) sel_logit[0]) / sum;
if (cum >= (double) p.top_p) { cut = i + 1; break; }
}
if (cut < p.min_keep) cut = p.min_keep < n_minp ? p.min_keep : n_minp;
n_keep = cut;
}
const float inv_t = p.temperature > 0.0f ? 1.0f / p.temperature : 0.0f;
auto scaled = [&](int i) { return penal(sel_logit[(size_t) i] * inv_t, count(sel_ids[(size_t) i])); };
float smx = scaled(0);
for (int i = 1; i < n_keep; ++i) smx = std::fmax(smx, scaled(i));
double sum = 0.0;
for (int i = 0; i < n_keep; ++i) sum += std::exp((double) scaled(i) - (double) smx);
const float u = host_philox_uniform(p.seed, p.counter + (uint64_t) row);
double cum = 0.0;
int pick = sel_ids[(size_t) (n_keep - 1)];
for (int i = 0; i < n_keep; ++i) {
cum += std::exp((double) scaled(i) - (double) smx) / sum;
if ((double) u < cum) { pick = sel_ids[(size_t) i]; break; }
}
return pick;
}
// Survivors after the min_p + top_p cuts, in the sampled chain - the quantity an order or a threshold
// actually changes, used to assert a fixture can SEE the feature before asserting the kernel matches.
int sampled_cut(const std::vector<float>& l, const std::vector<int>& hist, const strata::kernels::SamplerParams& p) {
auto penal = [&](float logit, int count) {
if (count <= 0) return logit;
if (logit <= 0.0f) logit *= p.penalty_repeat; else logit /= p.penalty_repeat;
logit -= (float) count * p.penalty_freq + (count > 0 ? 1.0f : 0.0f) * p.penalty_present;
return logit;
};
auto count = [&](int v) { int c = 0; for (int h : hist) if (h == v) ++c; return c; };
const int nv = (int) l.size();
const int KMAX = 64;
const int k = p.top_k > 0 ? (p.top_k < KMAX ? p.top_k : KMAX) : 0;
std::vector<float> sel;
std::vector<char> taken((size_t) nv, 0);
for (int i = 0; i < k; ++i) {
int best = -1; float bv = 0;
for (int v = 0; v < nv; ++v) {
if (taken[(size_t) v]) continue;
const float s = penal(l[(size_t) v], count(v));
if (best < 0 || s > bv) { best = v; bv = s; }
}
taken[(size_t) best] = 1; sel.push_back(bv);
}
int n_minp = (int) sel.size();
if (p.min_p > 0.0f) {
const float thresh = sel[0] + std::log(p.min_p);
for (int i = 0; i < (int) sel.size(); ++i)
if (sel[(size_t) i] < thresh) { n_minp = i; break; }
}
if (p.top_p >= 1.0f) return n_minp;
double sum = 0.0;
for (int i = 0; i < n_minp; ++i) sum += std::exp((double) sel[(size_t) i] - (double) sel[0]);
double cum = 0.0;
int cut = n_minp;
for (int i = 0; i < n_minp; ++i) {
cum += std::exp((double) sel[(size_t) i] - (double) sel[0]) / sum;
if (cum >= (double) p.top_p) { cut = i + 1; break; }
}
if (cut < p.min_keep) cut = p.min_keep < n_minp ? p.min_keep : n_minp;
return cut;
}
} // namespace
int main(int argc, char** argv) {
@@ -326,6 +460,116 @@ int main(int argc, char** argv) {
bad += run("T=1 greedy=true is the argmax", l, NT3, p2, want);
}
// ---- fixture 6: PENALTIES IN THE SAMPLED CHAIN. Fixture 4 pins the greedy (argmax) path; the sampled
// chain applies penalties a SECOND time - after the temperature scaling, on the survivors - so it gets its
// own reference (the full chain with the host Philox) and its own observability check: with the penalties
// on, the history row's favourite must LOSE a pick it would win penalty-free.
{
const int NV2 = 8, NT2 = 2;
strata::kernels::SamplerParams p;
p.top_k = 5; p.top_p = 0.9f; p.temperature = 0.8f; p.seed = 9; p.counter = 0;
p.penalty_last_n = 4; p.penalty_repeat = 3.0f; p.penalty_freq = 0.2f; p.penalty_present = 0.6f;
std::vector<float> l((size_t) NV2 * NT2, -8.0f);
std::vector<int> hist((size_t) NT2 * 4, -1);
for (int t = 0; t < NT2; ++t) {
float* row = l.data() + (size_t) t * NV2;
row[0] = 9.0f; row[1] = 4.5f; row[2] = 4.4f; row[3] = 4.3f; // token 0 leads clean (9 vs 4.5)
hist[(size_t) t * 4 + 0] = 0; // and falls to 2.2/2.0 penalised
hist[(size_t) t * 4 + 1] = t == 1 ? 0 : -1; // (repeat 3, freq, presence)
}
std::vector<int> want((size_t) NT2), clean((size_t) NT2);
strata::kernels::SamplerParams clean_p = p;
clean_p.penalty_last_n = 0; clean_p.penalty_repeat = 1.0f;
clean_p.penalty_freq = 0.0f; clean_p.penalty_present = 0.0f;
for (int t = 0; t < NT2; ++t) {
const std::vector<float> row(l.begin() + (size_t) t * NV2, l.begin() + (size_t) (t + 1) * NV2);
const std::vector<int> h(hist.begin() + (size_t) t * 4, hist.begin() + (size_t) (t + 1) * 4);
want[(size_t) t] = sampled_reference(row, h, p, t);
clean[(size_t) t] = sampled_reference(row, h, clean_p, t);
}
const bool visible = want[0] != clean[0] || want[1] != clean[1];
std::printf(" %-34s %s (penalised picks %d/%d, clean %d/%d)\n",
"sampled penalties are observable", visible ? "yes" : "*** NO ***", want[0], want[1],
clean[0], clean[1]);
if (!visible) ++bad;
else bad += run("sampled chain: penalties + top_k/p", l, NT2, p, want, hist, 4);
}
// ---- fixture 7: MIN_P. The cut is a PREFIX of the descending top_k list (logit >= max + log(min_p)),
// so the fixture asserts the survivor count moves with the threshold (the observability half) and that
// the kernel's pick equals the reference's through the full sampled chain (the correctness half).
{
const int NV3 = 8, NT3 = 2;
std::vector<float> l((size_t) NV3 * NT3, -8.0f);
for (int t = 0; t < NT3; ++t) {
float* row = l.data() + (size_t) t * NV3;
row[0] = 4.0f; row[1] = 3.5f; row[2] = 3.2f; row[3] = 3.1f; // gaps keep the cut off the
row[4] = 2.0f; // logf/rounding knife edge
}
strata::kernels::SamplerParams base;
base.top_k = 6; base.top_p = 1.0f; base.temperature = 0.9f; base.seed = 77;
int c0 = 0, c05 = 0, c09 = 0;
for (int t = 0; t < NT3; ++t) {
const std::vector<float> row(l.begin() + (size_t) t * NV3, l.begin() + (size_t) (t + 1) * NV3);
strata::kernels::SamplerParams q = base; q.min_p = 0.0f;
c0 += sampled_cut(row, {}, q);
q.min_p = 0.5f; c05 += sampled_cut(row, {}, q);
q.min_p = 0.9f; c09 += sampled_cut(row, {}, q);
}
const bool visible = c0 > c05 && c05 > c09 && c09 >= NT3;
std::printf(" %-34s %s (survivors: min_p 0 -> %d, 0.5 -> %d, 0.9 -> %d)\n",
"min_p cut is observable", visible ? "yes" : "*** NO ***", c0, c05, c09);
if (!visible) ++bad;
for (float mp : {0.0f, 0.5f, 0.9f}) {
strata::kernels::SamplerParams q = base; q.min_p = mp;
std::vector<int> want((size_t) NT3);
for (int t = 0; t < NT3; ++t) {
const std::vector<float> row(l.begin() + (size_t) t * NV3, l.begin() + (size_t) (t + 1) * NV3);
want[(size_t) t] = sampled_reference(row, {}, q, t);
}
char name[64];
std::snprintf(name, sizeof name, "sampled chain: min_p=%.1f", (double) mp);
bad += run(name, l, NT3, q, want);
}
}
// ---- fixture 8: THE PENALTY WINDOW IS THE TAIL. With an 8-entry history and penalty_last_n = 4, only
// the LAST four entries count: a token punished in the old half must come back to full strength, and one
// punished in the tail half stays down. The reference counts the same tail; the observability check runs
// the reference once more WITHOUT the clamp (counting all 8) and requires the picks to differ.
{
const int NV4 = 8;
strata::kernels::SamplerParams p;
p.top_k = 0; p.top_p = 1.0f; p.temperature = 1.0f; p.greedy = true;
p.penalty_last_n = 4; p.penalty_repeat = 3.0f; p.penalty_freq = 0.3f; p.penalty_present = 0.5f;
std::vector<float> l((size_t) NV4, -8.0f);
l[0] = 6.0f; l[3] = 6.5f; // token 0 leads clean; token 3 is the tail offender
std::vector<int> hist = {0, 0, 0, 0, 3, 3, 3, 3}; // token 0 old (out), token 3 in the tail
auto pick_clamped = [&](bool clamp) {
int best = 0; float bv = 0; bool first = true;
for (int v = 0; v < NV4; ++v) {
int c = 0;
for (int i = 0; i < (clamp ? 4 : 8); ++i) if (hist[(size_t) (8 - (clamp ? 4 : 8) + i)] == v) ++c;
float logit = l[(size_t) v];
if (c > 0) { logit = logit <= 0.0f ? logit * p.penalty_repeat : logit / p.penalty_repeat;
logit -= (float) c * p.penalty_freq + p.penalty_present; }
if (first || logit > bv) { bv = logit; best = v; first = false; }
}
return best;
};
const int want = pick_clamped(true), unclamped = pick_clamped(false);
const bool visible = want != unclamped;
std::printf(" %-34s %s (clamped pick %d, full-history pick %d)\n",
"penalty window clamp is observable", visible ? "yes" : "*** NO ***", want, unclamped);
if (!visible) ++bad;
else bad += run("penalty window: tail only", {l.begin(), l.end()}, 1, p, {want}, hist, 8);
}
// A continuous stream and individual decode calls consume the same draw counters.
{
constexpr int count = 32, vocab = 16;
+7
View File
@@ -44,6 +44,13 @@ bool Gemm::init_external(void* stream, uint16_t* scratch, int64_t scratch_elems,
return true;
}
void Gemm::rebind(uint16_t* scratch, int64_t scratch_elems, void* workspace, size_t ws_bytes) {
scratch_ = scratch;
scratch_elems_ = scratch_elems;
workspace_ = workspace;
cublasSetWorkspace((cublasHandle_t) handle_, workspace_, ws_bytes);
}
bool Gemm::init(void* stream, int64_t scratch_elems, std::string& err) {
cublasHandle_t h = nullptr;
if (cublasCreate(&h) != CUBLAS_STATUS_SUCCESS) { err = "prefill gemm: cublasCreate failed"; return false; }
+82
View File
@@ -0,0 +1,82 @@
// src/prefill/ggml_cuda_host.cu - prompt-speed plan step 2b: the host-side symbols of llama.cpp's ggml-cuda that its MMQ
// and quantize code reference, for the MMQ kernels compiled into strata_mmq without the rest of ggml-cuda.cu.
#include "common.cuh"
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <vector>
[[noreturn]] void ggml_cuda_error(const char * stmt, const char * func, const char * file, int line, const char * msg) {
std::fprintf(stderr, "ggml-cuda (strata mmq): %s: %s\n in %s at %s:%d\n", msg, stmt, func, file, line);
std::abort();
}
int ggml_cuda_get_device() {
int id = 0;
CUDA_CHECK(cudaGetDevice(&id));
return id;
}
const ggml_cuda_device_info & ggml_cuda_info() {
static ggml_cuda_device_info info = [] {
ggml_cuda_device_info in = {};
int n = 0;
if (cudaGetDeviceCount(&n) != cudaSuccess) n = 0;
n = n > GGML_CUDA_MAX_DEVICES ? GGML_CUDA_MAX_DEVICES : n;
in.device_count = n;
in.physical_device_count = n;
for (int id = 0; id < n; ++id) {
cudaDeviceProp prop;
CUDA_CHECK(cudaGetDeviceProperties(&prop, id));
auto & d = in.devices[id];
d.cc = 100 * prop.major + 10 * prop.minor;
d.nsm = prop.multiProcessorCount;
d.smpb = prop.sharedMemPerBlock;
d.smpbo = prop.sharedMemPerBlockOptin;
d.integrated = prop.integrated != 0;
d.vmm = false;
d.total_vram = prop.totalGlobalMem;
d.warp_size = prop.warpSize;
d.supports_cooperative_launch = prop.cooperativeLaunch != 0;
d.physical_device = id;
d.physical_share_count = 1;
d.virtual_index = 0;
}
return in;
}();
return info;
}
namespace {
// Buffers are kept and reused: MMQ asks for the same few sizes every launch (its stream-k fixup tiles).
struct CachingPool : ggml_cuda_pool {
struct Buf { void * p; size_t size; bool used; };
std::vector<Buf> bufs;
std::mutex mu;
void * alloc(size_t size, size_t * actual_size) override {
std::lock_guard<std::mutex> lk(mu);
for (auto & b : bufs)
if (!b.used && b.size >= size) { b.used = true; *actual_size = b.size; return b.p; }
void * p = nullptr;
CUDA_CHECK(cudaMalloc(&p, size));
bufs.push_back({p, size, true});
*actual_size = size;
return p;
}
void free(void * ptr, size_t) override {
std::lock_guard<std::mutex> lk(mu);
for (auto & b : bufs)
if (b.p == ptr) { b.used = false; return; }
}
~CachingPool() override {
for (auto & b : bufs) cudaFree(b.p);
}
};
} // namespace
std::unique_ptr<ggml_cuda_pool> ggml_backend_cuda_context::new_pool_for_device(int, int) {
return std::unique_ptr<ggml_cuda_pool>(new CachingPool());
}
ggml_backend_cuda_context::~ggml_backend_cuda_context() {}
+50 -17
View File
@@ -1,6 +1,7 @@
// src/prefill/kernels.cu - see include/strata/prefill/kernels.hpp.
#include "strata/prefill/kernels.hpp"
#include "strata/kernels/mrope.hpp"
#include "strata/kernels/router_top10.hpp"
#include <cuda_fp16.h>
#include <cuda_runtime.h>
@@ -201,31 +202,32 @@ __global__ void __launch_bounds__(S * RG) gdn_rec_kernel(float* __restrict__ sta
}
// ---------------------------------------------------------------- MoE
template <int REG>
__global__ void route_kernel(const float* __restrict__ logits, int32_t* __restrict__ ids, float* __restrict__ wout,
int64_t T) {
const int64_t t = (int64_t) blockIdx.x * (blockDim.x >> 5) + (threadIdx.x >> 5);
if (t >= T) return;
const int lane = threadIdx.x & 31;
const float* lg = logits + t * 512;
float v[16];
const float* lg = logits + t * (REG * 32);
float v[REG];
#pragma unroll
for (int i = 0; i < 16; ++i) v[i] = lg[lane + i * 32];
for (int i = 0; i < REG; ++i) v[i] = lg[lane + i * 32];
float mx = -INFINITY;
#pragma unroll
for (int i = 0; i < 16; ++i) mx = fmaxf(mx, v[i]);
for (int i = 0; i < REG; ++i) mx = fmaxf(mx, v[i]);
mx = warp_max(mx);
float sum = 0.0f;
#pragma unroll
for (int i = 0; i < 16; ++i) { v[i] = expf(v[i] - mx); sum += v[i]; }
for (int i = 0; i < REG; ++i) { v[i] = expf(v[i] - mx); sum += v[i]; }
const float rcp = 1.0f / warp_sum(sum);
#pragma unroll
for (int i = 0; i < 16; ++i) { v[i] *= rcp; if (isnan(v[i])) v[i] = -FLT_MAX; }
for (int i = 0; i < REG; ++i) { v[i] *= rcp; if (isnan(v[i])) v[i] = -FLT_MAX; }
float selected = 0.0f, selected_sum = 0.0f;
for (int rank = 0; rank < 10; ++rank) {
float best = v[0];
int ex = lane;
#pragma unroll
for (int i = 1; i < 16; ++i) if (v[i] > best) { best = v[i]; ex = lane + i * 32; }
for (int i = 1; i < REG; ++i) if (v[i] > best) { best = v[i]; ex = lane + i * 32; }
#pragma unroll
for (int m = 16; m; m >>= 1) {
const float ob = __shfl_xor_sync(0xffffffffu, best, m);
@@ -335,10 +337,12 @@ __global__ void gate_attn_kernel(const float* __restrict__ a, const float* __res
o16[i] = hf(a[i] * (1.0f / (1.0f + expf(-qf[t * 24 * 512 + h * 512 + 256 + d]))));
}
// one block per (token, kv head, 64-value group); 64 threads
// one block per (token, kv head, 64-value group); 64 threads. KV streaming: the pool page only if the block is
// resident (table >= 0), and the host copy and the prompt path's staging pool (both identity layout) when given.
__global__ void kv_append_kernel(const float* __restrict__ K, const float* __restrict__ V, int64_t pos0,
const int32_t* __restrict__ table, int64_t page_size, uint16_t* k_pool,
uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale) {
uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale,
strata::kernels::KvHostPools host, strata::kernels::KvHostPools stage) {
const int64_t t = blockIdx.x;
const int kvh = blockIdx.y, g = blockIdx.z >> 1;
const bool is_v = (blockIdx.z & 1) != 0;
@@ -347,8 +351,12 @@ __global__ void kv_append_kernel(const float* __restrict__ K, const float* __res
const int64_t pos = pos0 + t;
const int64_t page = table[pos / page_size];
const int64_t row = (page * 2 + kvh) * page_size + pos % page_size;
const int64_t row_id = ((pos / page_size) * 2 + kvh) * page_size + pos % page_size;
if (k_pool != nullptr) {
(is_v ? v_pool : k_pool)[row * 256 + d] = hf(x);
const uint16_t h = hf(x);
if (page >= 0) (is_v ? v_pool : k_pool)[row * 256 + d] = h;
if (host.k_pool != nullptr) (is_v ? host.v_pool : host.k_pool)[row_id * 256 + d] = h;
if (stage.k_pool != nullptr) (is_v ? stage.v_pool : stage.k_pool)[row_id * 256 + d] = h;
return;
}
float a = fabsf(x);
@@ -361,13 +369,27 @@ __global__ void kv_append_kernel(const float* __restrict__ K, const float* __res
const float sf = __half2float(__ushort_as_half(sb));
int q = 0;
if (sf > 0.0f) { q = __float2int_rn(x / sf); q = q < -127 ? -127 : (q > 127 ? 127 : q); }
(is_v ? v_q : k_q)[row * 256 + d] = (int8_t) q;
if (threadIdx.x == 0) (is_v ? v_scale : k_scale)[row * 4 + g] = sb;
if (page >= 0) {
(is_v ? v_q : k_q)[row * 256 + d] = (int8_t) q;
if (threadIdx.x == 0) (is_v ? v_scale : k_scale)[row * 4 + g] = sb;
}
if (host.k_q != nullptr) {
(is_v ? host.v_q : host.k_q)[row_id * 256 + d] = (int8_t) q;
if (threadIdx.x == 0) (is_v ? host.v_scale : host.k_scale)[row_id * 4 + g] = sb;
}
if (stage.k_q != nullptr) {
(is_v ? stage.v_q : stage.k_q)[row_id * 256 + d] = (int8_t) q;
if (threadIdx.x == 0) (is_v ? stage.v_scale : stage.k_scale)[row_id * 4 + g] = sb;
}
}
__global__ void to_f16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int64_t n) {
for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t) gridDim.x * blockDim.x)
y[i] = hf(x[i]);
}
__global__ void round_f16_kernel(const float* __restrict__ x, float* __restrict__ y, int64_t n) {
for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t) gridDim.x * blockDim.x)
y[i] = __half2float(__float2half_rn(x[i]));
}
__global__ void to_bf16_kernel(const float* __restrict__ x, uint16_t* __restrict__ y, int64_t n) {
for (int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; i < n; i += (int64_t) gridDim.x * blockDim.x)
y[i] = bf(x[i]);
@@ -377,10 +399,11 @@ __global__ void to_bf16_kernel(const float* __restrict__ x, uint16_t* __restrict
void kv_append(const float* K, const float* V, int64_t T, int64_t pos0, const int32_t* page_table, int64_t page_size,
uint16_t* k_pool, uint16_t* v_pool, int8_t* k_q, int8_t* v_q, uint16_t* k_scale, uint16_t* v_scale,
void* stream) {
void* stream, const strata::kernels::KvHostPools* host, const strata::kernels::KvHostPools* stage) {
if (T <= 0) return;
kv_append_kernel<<<dim3((unsigned) T, 2, 8), 64, 0, (cudaStream_t) stream>>>(K, V, pos0, page_table, page_size, k_pool,
v_pool, k_q, v_q, k_scale, v_scale);
kv_append_kernel<<<dim3((unsigned) T, 2, 8), 64, 0, (cudaStream_t) stream>>>(
K, V, pos0, page_table, page_size, k_pool, v_pool, k_q, v_q, k_scale, v_scale,
host ? *host : strata::kernels::KvHostPools{}, stage ? *stage : strata::kernels::KvHostPools{});
check("kv_append");
}
void to_f16(const float* x, uint16_t* y, int64_t n, void* stream) {
@@ -388,6 +411,11 @@ void to_f16(const float* x, uint16_t* y, int64_t n, void* stream) {
to_f16_kernel<<<(unsigned) ((n + 255) / 256 < 4096 ? (n + 255) / 256 : 4096), 256, 0, (cudaStream_t) stream>>>(x, y, n);
check("to_f16");
}
void round_f16(const float* x, float* y, int64_t n, void* stream) {
if (n <= 0) return;
round_f16_kernel<<<(unsigned) ((n + 255) / 256 < 4096 ? (n + 255) / 256 : 4096), 256, 0, (cudaStream_t) stream>>>(x, y, n);
check("round_f16");
}
void to_bf16(const float* x, uint16_t* y, int64_t n, void* stream) {
if (n <= 0) return;
to_bf16_kernel<<<(unsigned) ((n + 255) / 256 < 4096 ? (n + 255) / 256 : 4096), 256, 0, (cudaStream_t) stream>>>(x, y, n);
@@ -429,8 +457,13 @@ void gdn_recurrence(float* state, const float* h, const float* gate, const float
gdn_rec_kernel<<<HV, dim3(S, RG), 0, (cudaStream_t) stream>>>(state, h, gate, beta, z, gamma, eps, y, y16, T);
check("gdn_recurrence");
}
void route(const float* logits, int32_t* ids, float* weights, int64_t T, void* stream) {
route_kernel<<<(unsigned) ((T + 7) / 8), 256, 0, (cudaStream_t) stream>>>(logits, ids, weights, T);
void route(const float* logits, int32_t* ids, float* weights, int64_t T, int64_t n_expert, void* stream) {
if (n_expert == 512)
route_kernel<16><<<(unsigned) ((T + 7) / 8), 256, 0, (cudaStream_t) stream>>>(logits, ids, weights, T);
else if (n_expert == 256)
route_kernel<8><<<(unsigned) ((T + 7) / 8), 256, 0, (cudaStream_t) stream>>>(logits, ids, weights, T);
else
strata::kernels::router_top10(logits, (int) T, (int) n_expert, 10, ids, weights, stream);
check("route");
}
void blob_dequant(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* stream) {
+186
View File
@@ -0,0 +1,186 @@
// src/prefill/moe_mmq.cu - see include/strata/prefill/moe_mmq.hpp. llama.cpp's MMQ (ggml-cuda, MIT) is compiled
// from the pinned llama.cpp checkout the build already takes ggml from; src/prefill/ggml_cuda_host.cu supplies the
// few host symbols of ggml-cuda.cu it references.
#include "strata/prefill/moe_mmq.hpp"
#include "common.cuh"
#include "mmq.cuh"
#include "quantize.cuh"
#include <cstdio>
#include <cstdlib>
namespace strata::prefill::mmq {
namespace {
void ck(cudaError_t e, const char* what) {
if (e != cudaSuccess) {
std::fprintf(stderr, "prefill mmq: %s: %s\n", what, cudaGetErrorString(e));
std::exit(1);
}
}
int64_t pad512(int64_t n) { return (n + 511) / 512 * 512; }
__global__ void copy16_kernel(const uint4* __restrict__ a, int64_t na, const uint4* __restrict__ b, int64_t nb,
uint4* __restrict__ ab_dst, const uint4* __restrict__ c, int64_t nc, uint4* __restrict__ c_dst) {
const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x;
if (i < na) ab_dst[i] = a[i];
else if (i < na + nb) ab_dst[i] = b[i - na];
else if (i < na + nb + nc) c_dst[i - na - nb] = c[i - na - nb];
}
__global__ void copy1_kernel(const uint8_t* __restrict__ a, int64_t na, const uint8_t* __restrict__ b, int64_t nb,
uint8_t* __restrict__ ab_dst, const uint8_t* __restrict__ c, int64_t nc, uint8_t* __restrict__ c_dst) {
const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x;
if (i < na) ab_dst[i] = a[i];
else if (i < na + nb) ab_dst[i] = b[i - na];
else if (i < na + nb + nc) c_dst[i - na - nb] = c[i - na - nb];
}
// Strata blob: gate/up codes [1280][640 B], down codes [2560][160 B], gate/up scales [1280][40] f16, down scales
// [2560][10] f16 (the layout of prefill/kernels.cu's blob_dequant_kernel). A GGUF Q2_0 block is {f16 d; 16 code
// bytes} with the same 2-bit codes in the same order, so a block is a scale and a 16-byte run of codes.
__global__ void strata_q2_kernel(const uint8_t* __restrict__ blob, uint16_t* __restrict__ gu, uint16_t* __restrict__ dn) {
constexpr size_t O_D_CODES = (size_t) 1280 * 640, O_GU_SC = O_D_CODES + (size_t) 2560 * 160,
O_D_SC = O_GU_SC + (size_t) 1280 * 40 * 2;
const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x; // one GGUF block
const int64_t n_gu = 1280LL * 40, n_d = 2560LL * 10;
const uint8_t* codes;
const uint16_t* scale;
uint16_t* out;
if (i < n_gu) {
const int64_t row = i / 40, b = i % 40;
codes = blob + row * 640 + b * 16;
scale = (const uint16_t*) (blob + O_GU_SC) + row * 40 + b;
out = gu + i * 9;
} else if (i < n_gu + n_d) {
const int64_t j = i - n_gu, row = j / 10, b = j % 10;
codes = blob + O_D_CODES + row * 160 + b * 16;
scale = (const uint16_t*) (blob + O_D_SC) + row * 10 + b;
out = dn + j * 9;
} else {
return;
}
const uint4 q = *(const uint4*) codes;
const uint16_t* qh = (const uint16_t*) &q;
out[0] = *scale;
#pragma unroll
for (int k = 0; k < 8; ++k) out[1 + k] = qh[k];
}
__global__ void swiglu_kernel(const float* __restrict__ gu, float* __restrict__ h, int64_t rows, int64_t n_ff,
bool interleaved) {
const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x;
if (i >= rows * n_ff) return;
const int64_t r = i / n_ff, k = i % n_ff;
const float* row = gu + r * 2 * n_ff;
const float g = interleaved ? row[2 * k] : row[k], u = interleaved ? row[2 * k + 1] : row[n_ff + k];
h[i] = g / (1.0f + __expf(-g)) * u;
}
__global__ void iota_kernel(int32_t* dst, int64_t n) {
const int64_t i = (int64_t) blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) dst[i] = (int32_t) i;
}
unsigned blocks(int64_t n) { return (unsigned) ((n + 255) / 256); }
} // namespace
bool built() { return true; }
bool supported(int t) {
switch ((ggml_type) t) {
case GGML_TYPE_Q2_0: case GGML_TYPE_IQ2_XXS: case GGML_TYPE_IQ2_XS: case GGML_TYPE_IQ2_S:
case GGML_TYPE_IQ3_XXS: case GGML_TYPE_IQ3_S: case GGML_TYPE_IQ4_NL: case GGML_TYPE_IQ4_XS:
return true;
default:
return false;
}
}
size_t matrix_bytes(int t, int64_t rows, int64_t cols) {
return (size_t) rows * (size_t) (cols / ggml_blck_size((ggml_type) t)) * ggml_type_size((ggml_type) t);
}
size_t q8_bytes(int64_t rows, int64_t cols) {
return (size_t) rows * (size_t) pad512(cols) * sizeof(block_q8_1_mmq) / (4 * QK8_1) + 128 * sizeof(block_q8_1_mmq);
}
void quantize(const float* x, const int32_t* ids, void* xq, int t, int64_t cols, int64_t ld, int64_t rows, void* stream) {
if (rows <= 0) return;
quantize_mmq_q8_1_cuda(x, ids, xq, (ggml_type) t, cols, ld, rows * ld, rows * ld, pad512(cols), rows, 1, 1,
(cudaStream_t) stream);
ck(cudaGetLastError(), "quantize");
}
Context::Context() {
int dev = 0;
cudaGetDevice(&dev);
ctx_ = new ggml_backend_cuda_context(dev);
}
Context::~Context() { delete (ggml_backend_cuda_context*) ctx_; }
void Context::run(const Product& p, void* stream) {
if (p.n <= 0 || p.max_rows <= 0) return;
const ggml_type t = (ggml_type) p.type;
const int64_t qk = ggml_blck_size(t), bpr = p.w_cols / qk;
const mmq_args a = {(const char*) p.w, t, (const int*) p.xq, p.ids, p.bounds, p.dst, nullptr,
p.w_cols, p.w_rows, p.total_rows, bpr, p.total_rows, p.ld_dst,
p.n, p.n, (int64_t) (p.expert_bytes / ggml_type_size(t)), 0, 0,
1, 1, 0, 0, 0,
p.max_rows, p.max_rows};
auto& ctx = *(ggml_backend_cuda_context*) ctx_;
const cudaStream_t s = (cudaStream_t) stream;
switch (t) {
case GGML_TYPE_Q2_0: mul_mat_q_case<GGML_TYPE_Q2_0>(ctx, a, s); break;
case GGML_TYPE_IQ2_XXS: mul_mat_q_case<GGML_TYPE_IQ2_XXS>(ctx, a, s); break;
case GGML_TYPE_IQ2_XS: mul_mat_q_case<GGML_TYPE_IQ2_XS>(ctx, a, s); break;
case GGML_TYPE_IQ2_S: mul_mat_q_case<GGML_TYPE_IQ2_S>(ctx, a, s); break;
case GGML_TYPE_IQ3_XXS: mul_mat_q_case<GGML_TYPE_IQ3_XXS>(ctx, a, s); break;
case GGML_TYPE_IQ3_S: mul_mat_q_case<GGML_TYPE_IQ3_S>(ctx, a, s); break;
case GGML_TYPE_IQ4_NL: mul_mat_q_case<GGML_TYPE_IQ4_NL>(ctx, a, s); break;
case GGML_TYPE_IQ4_XS: mul_mat_q_case<GGML_TYPE_IQ4_XS>(ctx, a, s); break;
default:
std::fprintf(stderr, "prefill mmq: type %d is not covered\n", (int) t);
std::exit(1);
}
ck(cudaGetLastError(), "mul_mat_q");
}
void gather_native(const void* gate, const void* up, size_t gu_half_bytes, const void* down, size_t d_bytes,
void* gu_dst, void* d_dst, void* stream) {
const cudaStream_t s = (cudaStream_t) stream;
const bool a16 = ((uintptr_t) gate | (uintptr_t) up | (uintptr_t) down | (uintptr_t) gu_dst | (uintptr_t) d_dst |
gu_half_bytes | d_bytes) % 16 == 0;
if (a16) {
const int64_t na = (int64_t) gu_half_bytes / 16, nc = (int64_t) d_bytes / 16;
copy16_kernel<<<blocks(2 * na + nc), 256, 0, s>>>((const uint4*) gate, na, (const uint4*) up, na, (uint4*) gu_dst,
(const uint4*) down, nc, (uint4*) d_dst);
} else {
const int64_t na = (int64_t) gu_half_bytes, nc = (int64_t) d_bytes;
copy1_kernel<<<blocks(2 * na + nc), 256, 0, s>>>((const uint8_t*) gate, na, (const uint8_t*) up, na,
(uint8_t*) gu_dst, (const uint8_t*) down, nc, (uint8_t*) d_dst);
}
ck(cudaGetLastError(), "gather_native");
}
void gather_strata_q2(const uint8_t* blob, void* gu_dst, void* d_dst, void* stream) {
strata_q2_kernel<<<blocks(1280LL * 40 + 2560LL * 10), 256, 0, (cudaStream_t) stream>>>(blob, (uint16_t*) gu_dst,
(uint16_t*) d_dst);
ck(cudaGetLastError(), "gather_strata_q2");
}
void swiglu(const float* gu, float* h, int64_t rows, int64_t n_ff, bool interleaved, void* stream) {
if (rows <= 0) return;
swiglu_kernel<<<blocks(rows * n_ff), 256, 0, (cudaStream_t) stream>>>(gu, h, rows, n_ff, interleaved);
ck(cudaGetLastError(), "swiglu");
}
void iota(int32_t* dst, int64_t n, void* stream) {
if (n <= 0) return;
iota_kernel<<<blocks(n), 256, 0, (cudaStream_t) stream>>>(dst, n);
ck(cudaGetLastError(), "iota");
}
} // namespace strata::prefill::mmq
+970 -86
View File
File diff suppressed because it is too large. Load diff
+1281 -117
View File
File diff suppressed because it is too large. Load diff
+101
View File
@@ -0,0 +1,101 @@
// src/spec/draft_policy.cpp - see include/strata/spec/draft_policy.hpp.
#include "strata/spec/draft_policy.hpp"
#include <algorithm>
namespace strata::spec {
namespace {
// The shape of a round's cost by window size, relative to one token, used only for sizes not measured yet (the
// measured round times replace it). Between the RTX 5070's measured curves: +10 ms per token with every missed expert
// on the CPU (bench/results/2026-09-27-spec/window-cost), flatter with the default CPU/DMA split.
constexpr double kShape[DraftPolicy::kMaxT + 1] = {0.0, 1.0, 1.35, 1.7, 2.05, 2.45, 2.85, 3.25, 3.6};
constexpr double kCostAlpha = 0.1; // EMA weight of a new round time
constexpr double kTokAlpha = 0.05; // EMA weight of a new MTP window outcome
constexpr double kDecay = 0.97; // lookup counts: older windows fade
// Before a bucket has data: the longer the match, the likelier its continuation (llama.cpp's lookup decoding gates
// on the same thing); worth 4 observations, so a few real windows override it
constexpr double kPriorQ[DraftPolicy::kBuckets] = {0.75, 0.88, 0.93, 0.96};
constexpr double kPriorN = 4.0;
constexpr int kProbes = 3; // a lookup window size is tried this often before its guessed cost can veto it
} // namespace
DraftPolicy::DraftPolicy(int max_t, double margin)
: max_t_(std::clamp(max_t, 1, kMaxT)), margin_(margin) {}
int DraftPolicy::bucket(int match) {
return match < 6 ? 0 : match < 12 ? 1 : match < 24 ? 2 : 3;
}
double DraftPolicy::lookup_rate(int match) const {
const int b = bucket(match);
return (ok_[b] + kPriorN * kPriorQ[b]) / (ok_[b] + bad_[b] + kPriorN);
}
double DraftPolicy::cost_ms(int t) const {
t = std::clamp(t, 1, kMaxT);
if (cost_n_[t] > 0) return cost_[t];
// scale from the measured sizes, weighting each by how often it was seen
double num = 0.0, den = 0.0;
for (int u = 1; u <= kMaxT; ++u)
if (cost_n_[u] > 0) {
const double w = std::min(cost_n_[u], 20.0);
num += w * cost_[u] * kShape[t] / kShape[u];
den += w;
}
return den > 0 ? num / den : kShape[t];
}
double DraftPolicy::mtp_tokens(int t) const {
if (mtp_n_[t] > 0) return mtp_tok_[t];
return 1.0 + 0.7 * (t - 1); // before any MTP window of this size: a typical acceptance
}
DraftPolicy::Pick DraftPolicy::choose(int t_mtp, int lookup_k, int match) const {
Pick p;
p.t = std::clamp(t_mtp, 1, max_t_);
if (lookup_k <= 0) return p;
const double base = mtp_tokens(p.t) / cost_ms(p.t);
const double q = lookup_rate(match);
double e = 1.0, qi = 1.0, best = 0.0;
int best_t = 0;
for (int k = 1; k <= std::min(lookup_k, max_t_ - 1); ++k) {
qi *= q;
e += qi;
const double r = e / cost_ms(k + 1);
if (r > best) { best = r; best_t = k + 1; }
}
if (best_t > 0 && best > base * (1.0 + margin_)) {
p.lookup = true;
p.t = best_t;
return p;
}
// a guessed cost can keep the policy from ever measuring a size: the first few times a confident lookup would
// need a size not measured yet, it is tried (verification keeps the output; only the one round's speed is at stake)
const int t_full = std::min(lookup_k, max_t_ - 1) + 1;
if (t_full > p.t && cost_n_[t_full] < kProbes && q >= 0.85) {
p.lookup = true;
p.t = t_full;
}
return p;
}
void DraftPolicy::observe(bool lookup, int t, int accepted, int match, double round_ms) {
t = std::clamp(t, 1, kMaxT);
if (round_ms > 0) {
cost_[t] = cost_n_[t] > 0 ? (1.0 - kCostAlpha) * cost_[t] + kCostAlpha * round_ms : round_ms;
cost_n_[t] += 1.0;
}
if (lookup) {
const int b = bucket(match);
ok_[b] = kDecay * ok_[b] + accepted;
bad_[b] = kDecay * bad_[b] + (accepted < t - 1 ? 1.0 : 0.0);
} else {
const double got = accepted + 1.0;
mtp_tok_[t] = mtp_n_[t] > 0 ? (1.0 - kTokAlpha) * mtp_tok_[t] + kTokAlpha * got : got;
mtp_n_[t] += 1.0;
}
}
} // namespace strata::spec
+63
View File
@@ -0,0 +1,63 @@
// src/spec/draft_policy_test.cpp - DraftPolicy: when does a lookup window beat the MTP's?
//
// Simulated rounds with the costs measured on the RTX 5070 (window-cost: ~10 ms more per token) check that
// 1. with no lookup proposal the MTP window is kept;
// 2. lookup drafts that are mostly rejected stop being taken (their bucket's rate falls);
// 3. lookup drafts that are always accepted are taken, and the window grows with them;
// 4. match-length buckets learn separately (short matches failing does not stop long ones);
// 5. the policy never proposes a window beyond its cap.
#include "strata/spec/draft_policy.hpp"
#include <cstdio>
using strata::spec::DraftPolicy;
namespace {
int g_fail = 0;
void check(bool ok, const char* what) {
std::printf(" %-66s %s\n", what, ok ? "ok" : "FAIL");
if (!ok) ++g_fail;
}
double cost(int t) { return 19.0 + 10.5 * (t - 1); } // ms per round, measured shape
} // namespace
int main() {
std::printf("draft_policy_test\n");
{
DraftPolicy p(6);
for (int i = 0; i < 50; ++i) p.observe(false, 4, 2, 0, cost(4)); // MTP windows of 4: 3 tokens each
const DraftPolicy::Pick k = p.choose(4, 0, 0);
check(!k.lookup && k.t == 4, "no proposal: the MTP window");
}
{
DraftPolicy p(6);
for (int i = 0; i < 50; ++i) p.observe(false, 4, 2, 0, cost(4));
for (int t = 2; t <= 6; ++t) p.observe(false, t, 0, 0, cost(t));
for (int i = 0; i < 40; ++i) p.observe(true, 6, 0, 4, cost(6)); // short matches, all rejected
check(p.lookup_rate(4) < 0.15, "rejected short-match drafts: their rate falls below 0.15");
check(!p.choose(4, 5, 4).lookup, "rejected short-match drafts: no longer taken");
for (int i = 0; i < 40; ++i) p.observe(true, 6, 5, 30, cost(6)); // long matches, all accepted
check(p.lookup_rate(30) > 0.9, "accepted long-match drafts: their rate rises above 0.9");
const DraftPolicy::Pick k = p.choose(4, 5, 30);
check(k.lookup && k.t == 6, "accepted long matches: the full lookup window is taken");
check(!p.choose(4, 5, 4).lookup, "buckets are separate: short matches still not taken");
check(p.choose(4, 20, 30).t <= 6, "never beyond the window cap");
}
{
DraftPolicy p(8);
for (int i = 0; i < 50; ++i) p.observe(false, 3, 2, 0, cost(3)); // a very good MTP: 3 of 3 tokens
for (int t = 2; t <= 8; ++t) p.observe(false, t, t - 1, 0, cost(t));
for (int i = 0; i < 40; ++i) p.observe(true, 4, 2, 8, cost(4)); // lookup at q ~ 0.67
check(!p.choose(3, 7, 8).lookup, "a mediocre lookup does not replace a strong MTP window");
}
{
DraftPolicy p(6);
for (int i = 0; i < 50; ++i) p.observe(false, 4, 3, 0, cost(4)); // a near-perfect MTP, only size 4 seen
const DraftPolicy::Pick k = p.choose(4, 5, 40);
check(k.lookup && k.t == 6, "an unmeasured size is probed for a confident lookup");
for (int i = 0; i < 3; ++i) p.observe(true, 6, 5, 40, 3.0 * cost(6)); // it turns out very expensive
check(!p.choose(4, 5, 40).lookup, "after the probes, the measured cost decides");
}
std::printf(g_fail ? "FAIL\n" : "PASS\n");
return g_fail ? 1 : 0;
}
+1 -1
View File
@@ -14,7 +14,7 @@ ENGINE = Path(__file__).resolve().parents[1]
def gguf_py() -> str:
cands = [os.environ.get("STRATA_GGUF_PY"), ENGINE / "third_party" / "llama.cpp" / "gguf-py",
ENGINE.parents[1] / ".ref" / "llama.cpp" / "gguf-py"]
ENGINE.parents[min(1, len(ENGINE.parents) - 1)] / ".ref" / "llama.cpp" / "gguf-py"] # the dev tree; a shallow install (G:\Strata) has no grandparent
for c in cands:
if c and (Path(c) / "gguf").is_dir():
return str(c)
+10 -6
View File
@@ -227,20 +227,24 @@ def main() -> int:
# ---- the experts
n_layers = 1 + max(int(n.split(".")[1]) for n in T if n.startswith("blk.") and n.endswith("_exps.weight"))
n_expert = int(T["blk.0.ffn_gate_inp.weight"].shape[1]) # router rows = experts kept (pruned models ship < 512)
if any(int(T["blk.%d.ffn_gate_inp.weight" % l].shape[1]) != n_expert for l in range(n_layers)):
print("the routers disagree on the expert count; a per-layer pruned model cannot be packed")
return 1
layout, offset = [], 0
for l in range(n_layers):
ts = [T["blk.%d.ffn_%s_exps.weight" % (l, r)] for r in ROLES]
per = [t.expected_bytes() // N_EXPERT for t in ts]
per = [t.expected_bytes() // n_expert for t in ts]
if per[0] != per[1] or ts[0].type_name != ts[1].type_name:
print("layer %d: gate and up differ in type" % l)
return 1
blob = per[0] + per[1] + per[2]
layout.append((l, ts[0].type_id, ts[2].type_id, offset, blob, ts))
offset += blob * N_EXPERT
offset += blob * n_expert
with open(out / "native_experts.txt", "w", encoding="utf-8", newline="\n") as fo:
fo.write("# strata native experts v3: layer gu_type d_type offset blob_bytes gate_off up_off down_off [shard] "
"(n_expert %d, total %d; absolute offsets in %s, or in the named shard beside it)\n"
% (N_EXPERT, offset, src.name))
% (n_expert, offset, src.name))
for l, gt, dt, off, blob, ts in layout:
ws = [model.where[t.name] for t in ts]
if len({w[3] for w in ws}) != 1:
@@ -259,9 +263,9 @@ def main() -> int:
return 0
with open(path, "wb") as fo:
for l, gt, dt, off, blob, ts in layout:
parts = [model.bytes(t.name).reshape(N_EXPERT, -1) for t in ts]
chunk = np.concatenate(parts, axis=1) # (512, blob): gate | up | down per expert
assert chunk.shape == (N_EXPERT, blob)
parts = [model.bytes(t.name).reshape(n_expert, -1) for t in ts]
chunk = np.concatenate(parts, axis=1) # (n_expert, blob): gate | up | down per expert
assert chunk.shape == (n_expert, blob)
fo.write(chunk.tobytes())
if l % 8 == 0:
print(" layer %2d %-8s/%-7s blob %8d at %.2f GiB" % (l, ts[0].type_name, ts[2].type_name, blob,
+99
View File
@@ -0,0 +1,99 @@
"""tools/make_profile.py - the expert-cache profile (`data/expert-profile.bin`): every (layer, expert) pair, ranked.
`--expert-cache auto` fills the VRAM slots it can afford with the profile's pairs in order, so the ranking decides
which experts start resident (the adaptive tier then swaps in what a conversation routes most). A profile that
ranks fewer pairs than a card can hold caps the cache (issue #46: a 32 GB card stopped at 8,000 slots); this tool
writes one that ranks all 24,576.
The order: the base profile's ranking (default: the shipped data/expert-profile.bin), then the pairs your routing
traces used, most frequent first, then every pair still missing, interleaved across the layers.
python tools/make_profile.py [TRACE ...] [--base data/expert-profile.bin | --no-base] [--out PATH]
[--n-expert 256] (a pruned model: GSQ-RCO Coder keeps 256 of 512)
A routing trace comes from a one-shot engine run with `--dump-routing FILE` (a prompt typical of your use; the
routed experts of every layer and position are written). Point the model config's `--expert-profile` at the result.
"""
import argparse
import struct
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
N_LAYER, N_EXPERT = 48, 512
MAGIC, VERSION = b"STRP", 1
def read_profile(path, n_expert=N_EXPERT):
blob = Path(path).read_bytes()
if blob[:4] != MAGIC:
raise SystemExit(f"{path}: not a Strata profile")
ver, nl, ne, slots, n = struct.unpack_from("<5I", blob, 4)
if (nl, ne) != (N_LAYER, n_expert):
raise SystemExit(f"{path}: {nl}x{ne}, not {N_LAYER}x{n_expert}")
return [struct.unpack_from("<HH", blob, 24 + 4 * i) for i in range(n)]
def read_trace(path, n_expert=N_EXPERT):
"""(layer, k, k expert ids, k weights) records, as `--dump-routing` writes them."""
blob = Path(path).read_bytes()
off, freq = 0, defaultdict(int)
while off + 8 <= len(blob):
layer, k = struct.unpack_from("<ii", blob, off)
off += 8
for e in struct.unpack_from("<%di" % k, blob, off):
if 0 <= layer < N_LAYER and 0 <= e < n_expert:
freq[(layer, e)] += 1
off += 8 * k # the ids and the weights
return freq
def write_profile(path, ranked, n_expert=N_EXPERT):
table = [[-1] * n_expert for _ in range(N_LAYER)]
for slot, (layer, e) in enumerate(ranked):
table[layer][e] = slot
with open(path, "wb") as f:
f.write(MAGIC + struct.pack("<5I", VERSION, N_LAYER, n_expert, len(ranked), len(ranked)))
for layer, e in ranked:
f.write(struct.pack("<HH", layer, e))
for layer in range(N_LAYER):
f.write(struct.pack("<%di" % n_expert, *table[layer]))
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("traces", nargs="*", help="routing traces from --dump-routing")
ap.add_argument("--base", default=str(ROOT / "data" / "expert-profile.bin"), help="ranking to keep first")
ap.add_argument("--no-base", action="store_true", help="rank by the traces only")
ap.add_argument("--out", default=str(ROOT / "data" / "expert-profile.bin"))
ap.add_argument("--n-expert", type=int, default=N_EXPERT, help="experts per layer (default 512)")
a = ap.parse_args()
ranked, seen = [], set()
def take(pairs):
for p in pairs:
p = (int(p[0]), int(p[1]))
if p not in seen:
seen.add(p)
ranked.append(p)
ne = a.n_expert
if not a.no_base:
take(read_profile(a.base, ne))
n_base = len(ranked)
freq = defaultdict(int)
for t in a.traces:
for p, c in read_trace(t, ne).items():
freq[p] += c
take(p for p, _ in sorted(freq.items(), key=lambda kv: (-kv[1], kv[0])))
n_trace = len(ranked) - n_base
take((layer, e) for e in range(ne) for layer in range(N_LAYER)) # the rest, across the layers
write_profile(a.out, ranked, ne)
assert read_profile(a.out, ne) == ranked, "the profile did not survive the round trip"
print(f"wrote {a.out}: {len(ranked)} ranked pairs ({n_base} from the base, {n_trace} from the traces, "
f"{len(ranked) - n_base - n_trace} filled in)")
if __name__ == "__main__":
main()
-1
View File
@@ -19,7 +19,6 @@ from pathlib import Path
import numpy as np
STRATA = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _paths import add_gguf_py # noqa: E402
add_gguf_py()
+1 -1
View File
@@ -34,7 +34,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from gguf_reader import GGUFFile # noqa: E402
REPO = pathlib.Path(__file__).resolve().parents[1]
STRATA = REPO.parents[1] # the development layout's default shards (setup.py always passes --gguf)
STRATA = REPO.parents[min(1, len(REPO.parents) - 1)] # the dev layout's default shards (setup.py passes --gguf)
SHARD1 = STRATA / "Q2_0" / "Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00001-of-00002.gguf"
H, FF, NE = 2560, 640, 512
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more