Compare commits

...
20 Commits
Author SHA1 Message Date
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
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
45 changed files with 3856 additions and 435 deletions

No files matched your search

+1
View File
@@ -46,3 +46,4 @@ build-vision.bat
/dist/
*.done
/third_party/*.zip
+11 -1
View File
@@ -178,6 +178,7 @@ if(STRATA_ENABLE_CUDA)
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/qsa_decode_attn.cu
src/kernels/cuda/fused_gdn.cu
@@ -363,6 +364,12 @@ if(STRATA_ENABLE_CUDA)
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)
# 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)
@@ -424,7 +431,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)
@@ -433,6 +440,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
+3 -2
View File
@@ -44,7 +44,7 @@ model - is set up for you.
- **Images?** Whether it should also read pictures
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 a chat page at `http://127.0.0.1:8080`.
where it left off) and **starts the model**. Your browser opens the Strata app at `http://127.0.0.1:8080`.
**Next time**, just double-click `START-HERE.bat` again: it starts right away, nothing is downloaded twice. Close its
window to stop the model.
@@ -91,7 +91,8 @@ Not sure? Take **IQ2_XS**. You can add another one later with `START-HERE.bat --
## Using it
- **Chat in the browser:** `http://127.0.0.1:8080` - a simple chat page (it opens by itself when the model starts).
- **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`.
@@ -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.
+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.
+30 -5
View File
@@ -42,6 +42,11 @@ attention reads in VRAM (`--kv-resident 32768`), so more experts fit on the GPU.
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: about 7 s at 4K, 55 s at 32K, 4 minutes at 128K and 9 minutes at
262K. The raw numbers: [`bench/results/`](../bench/results/). The [paper](paper/Strata-Paper.pdf) explains every number.
@@ -125,8 +130,16 @@ Windows, `build-essential` + CUDA on Ubuntu) and compiles the engine for your GP
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.
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.
@@ -171,6 +184,7 @@ The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or
| 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 '{
@@ -215,8 +229,15 @@ assistant turn and every 16K prompt tokens). A checkpoint is used only when the
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); greedy decoding (temperature is ignored); images only when set up with them (below); no
video.
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.
---
@@ -325,7 +346,9 @@ test images.
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.
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)**.
@@ -344,3 +367,5 @@ The full story, with measurements, bottlenecks and what comes next: **[docs/pape
`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).
+11
View File
@@ -207,6 +207,10 @@ struct QsaState {
int8_t* v_q = nullptr;
uint16_t* k_scale = nullptr;
uint16_t* v_scale = nullptr;
/// 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;
@@ -269,6 +273,13 @@ uint64_t qsa_kv_host_bytes();
/// 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, int64_t ring_cells = 0);
/// KV streaming: the pools a reader sees (the VRAM slots) and, when streamed, make the selection's blocks resident.
+24
View File
@@ -24,6 +24,7 @@
#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>
@@ -58,6 +59,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 +99,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);
+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
+11 -5
View File
@@ -36,9 +36,15 @@ struct KvHostPools {
int8_t* v_q = nullptr;
uint16_t* k_scale = nullptr; ///< int8 mode: fp16 scale per 64 values
uint16_t* v_scale = nullptr;
bool present() const { return k_pool != nullptr || k_q != 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
@@ -62,7 +68,7 @@ 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, bool int8,
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);
@@ -71,12 +77,12 @@ void kv_ring_table(int32_t* page_table, int64_t n_blocks, int64_t n_slots, void*
/// 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, bool int8, int64_t b0, int64_t b1,
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, bool int8, int64_t n_blocks,
void kv_stage_from_host(const QsaAttnPools& stage, const KvHostPools& host, int fmt, int64_t n_blocks,
const QsaShapes& s, void* stream);
struct KvStreamCounters {
@@ -87,6 +93,6 @@ struct KvStreamCounters {
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, bool int8);
uint64_t kv_block_bytes(const QsaShapes& s, int fmt);
} // 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
+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
-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>
+236 -18
View File
@@ -10,6 +10,7 @@ data, http(s) URLs or local file paths) go through `strata-vision` (the model's
embeddings (`GENI`). JPEG/PNG/BMP/GIF go straight in; WebP, TIFF, AVIF, ... (agents like omp send WebP) are
converted to PNG first with Pillow.
Requests whose prompt plus max tokens exceed the engine's context are REJECTED with 400, never truncated.
An unset (or 0, or -1) max tokens means "unlimited": whatever the prompt leaves of the context.
The engine boundary is `Engine.generate(prompt_ids, max_new, sampling, cancel) -> iterator of token ids`.
`StrataEngine` keeps one `strata --serve` process resident (weights, expert arena and VRAM tier load once) and
@@ -18,6 +19,7 @@ talks to it over stdin/stdout; `MockEngine` is a scripted stand-in that makes ev
from __future__ import annotations
import argparse
import collections
import base64
import hashlib
import json
@@ -42,6 +44,7 @@ from serve.frontend import (ChatTemplate, Event, OutputParser, anthropic_to_mess
IM_END = "<|im_end|>"
IMAGE_PAD = "<|image_pad|>"
CTX_SLACK = 8 # `strata --serve` rejects prompt + max_new + 8 > context: keep the same margin here
# ------------------------------------------------------------------------------------------------ engines
@@ -71,7 +74,12 @@ class MockEngine:
class StrataEngine:
"""The resident engine: `strata --serve` reads `GEN <max_new> <ids>` lines and streams `T <id>` lines, then
`DONE ...`. Requests are serialized by the service's FIFO, so one pipe is enough."""
`DONE ...`. Requests are serialized by the service's FIFO, so one pipe is enough.
Per-request sampling rides the same line as engine-side keys between max_new and the ids
(`temperature=F top_p=F top_k=N seed=N`, the engine's own spelling). An absent temperature keeps the
engine's default, which is greedy; `temperature=0` means the same thing, so it is not forwarded.
"""
def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str | None = None,
env: dict | None = None):
@@ -81,7 +89,17 @@ class StrataEngine:
self.max_context = 0
self.can_stop = False # the engine honours a STOP line mid-request (READY <ctx> stop)
self.last = {}
self.info = {} # INFO key=value facts (engine 0.1.8+): kv, expert slots, ... (Monitor tab)
self.progress = None # (read, total) prompt tokens while a prompt is read, from PP lines
try: # a ready-made engine's BUILD.json says its version
self.info["version"] = json.loads((Path(exe).parent / "BUILD.json").read_text()).get("version")
except (OSError, ValueError):
self.info["version"] = None
for line in self.proc.stdout:
if line.startswith("INFO "):
for kv in line.split()[1:]:
k, _, v = kv.partition("=")
self.info[k] = int(v) if v.lstrip("-").isdigit() else v
if line.startswith("READY"):
f = line.split()
self.max_context = int(f[1])
@@ -102,12 +120,55 @@ class StrataEngine:
f = line.split()
self.last = {"generated": int(f[1]), "prompt_tokens": int(f[2]), "prompt_ms": float(f[3]),
"decode_ms": float(f[4]), "finish": f[5]}
if len(f) >= 9: # the conversation cache's fields (engine 0.1.3+)
self.last.update(drafts_accepted=int(f[6]), drafts_offered=int(f[7]), reused=int(f[8]))
@staticmethod
def sampling_keys(sampling: dict) -> str:
keys = ""
t = sampling.get("temperature")
if isinstance(t, (int, float)) and float(t) > 0.0:
keys += f" temperature={float(t)!r}"
tp = sampling.get("top_p")
if isinstance(tp, (int, float)) and float(tp) < 1.0:
keys += f" top_p={float(tp)!r}"
tk = sampling.get("top_k")
if isinstance(tk, int) and 1 <= tk <= 64:
keys += f" top_k={tk}" # the engine's sampled path takes 1..64; outside it keeps its 20
mp = sampling.get("min_p")
if isinstance(mp, (int, float)) and 0.0 < float(mp) <= 1.0:
keys += f" min_p={float(mp)!r}"
rp = sampling.get("repetition_penalty")
rp_on = isinstance(rp, (int, float)) and float(rp) != 1.0
pf = sampling.get("frequency_penalty")
pf_on = isinstance(pf, (int, float)) and float(pf) != 0.0
pp = sampling.get("presence_penalty")
pp_on = isinstance(pp, (int, float)) and float(pp) != 0.0
if rp_on:
keys += f" penalty_repeat={float(rp)!r}"
if pf_on:
keys += f" penalty_freq={float(pf)!r}"
if pp_on:
keys += f" penalty_present={float(pp)!r}"
if rp_on or pf_on or pp_on:
# a penalty without a window counts over nothing: the engine's default is the last 64 tokens
pln = sampling.get("penalty_last_n")
if isinstance(pln, int) and not isinstance(pln, bool) and pln > 0:
keys += f" penalty_last_n={pln}"
else:
keys += " penalty_last_n=64"
seed = sampling.get("seed")
if isinstance(seed, int) and seed > 0:
keys += f" seed={seed}"
return keys
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
"""Yields token ids, and None as a heartbeat every 10 s while the engine is quiet (reading a long prompt):
the HTTP layer turns it into an SSE comment, which keeps clients' watchdogs calm and notices a client that
has gone. A consumer that stops early (or `cancel`) makes the engine STOP, so it does not run to max_new."""
head = f"GENI {int(max_new)} {embeddings}" if embeddings else f"GEN {int(max_new)}"
self.progress = None
head = f"GENI {int(max_new)} {embeddings}" if embeddings else \
f"GEN {int(max_new)}{self.sampling_keys(sampling or {}) if not embeddings else ''}"
self.proc.stdin.write(f"{head} {','.join(str(int(t)) for t in ids)}\n")
self.proc.stdin.flush()
done = False
@@ -127,7 +188,10 @@ class StrataEngine:
if cancel.is_set():
return
yield int(line[2:])
elif line.startswith("PP "): # prompt progress, one per chunk: also a heartbeat (the
elif line.startswith("PP "):
f = line.split()
if len(f) >= 3 and f[1].isdigit() and f[2].isdigit():
self.progress = (int(f[1]), int(f[2])) # prompt progress, one per chunk: also a heartbeat (the
if cancel.is_set(): # lines reset the 10 s wait, so without this a long prompt
return # would send no keep-alives at all)
yield None
@@ -311,17 +375,62 @@ class Detokenizer:
class Service:
def __init__(self, engine: Engine, tokenizer, template: ChatTemplate, model_name: str = "qwen3.8-flash-next",
vision: Vision | None = None):
vision: Vision | None = None, sampling_defaults: dict | None = None):
self.engine, self.tok, self.template, self.model, self.vision = engine, tokenizer, template, model_name, vision
self.sampling_defaults = dict(sampling_defaults or {}) # the run config's `sampling` block
self.fifo = threading.Lock()
self.embeddings = threading.local() # the current request's image embeddings file (GENI)
self.api_key = "" # when set, /v1/* needs it (Bearer or x-api-key)
self.status = {"busy": False, "queued": 0} # GET /status: what the model is doing right now
self.history = collections.deque(maxlen=30) # the last finished requests, newest last (GET /metrics)
self.status_lock = threading.Lock()
self.stop_ids = set(tokenizer.encode(IM_END, parse_special=True) +
tokenizer.encode("<|endoftext|>", parse_special=True))
def prepare(self, messages, tools, kwargs, max_new):
def start_telemetry(self):
"""The hardware sampler behind GET /metrics (serve/telemetry.py), recording this server's tok/s too."""
if getattr(self, "telemetry", None) is None:
from serve.telemetry import Telemetry
self.telemetry = Telemetry(extra=lambda: {"tok_s": self._tok_s()})
def _tok_s(self):
with self.status_lock:
s = dict(self.status)
if not s.get("busy") or not s.get("first_token"):
return 0.0
return s["generated"] / max(1e-6, time.time() - s["first_token"])
def metrics(self) -> dict:
"""GET /metrics: what the Monitor tab shows - the engine's facts, what it is doing, the last requests, and
the hardware (with a minute of history per series)."""
with self.status_lock:
s = dict(self.status)
hist = list(self.history)
now = time.time()
progress = getattr(self.engine, "progress", None)
if s.get("busy") and s.get("first_token") is None:
state = "reading"
elif s.get("busy"):
state = "generating"
else:
state = "idle"
live = {"state": state, "queued": s.get("queued", 0), "phase": s.get("phase") if s.get("busy") else None,
"prompt_tokens": s.get("prompt_tokens") if s.get("busy") else None,
"prompt_read": None, "prompt_total": None, "generated": s.get("generated") if s.get("busy") else None,
"max_tokens": s.get("max_tokens") if s.get("busy") else None,
"elapsed_s": round(now - s["started"], 1) if s.get("busy") and s.get("started") else None,
"tok_s": round(self._tok_s(), 1) if state == "generating" else None}
if state == "reading" and progress:
live["prompt_read"], live["prompt_total"] = progress
engine = {"model": self.model, "max_context": self.engine.max_context, "images": self.vision is not None,
**dict(getattr(self.engine, "info", {}) or {})}
tel = self.telemetry.snapshot() if getattr(self, "telemetry", None) else {"now": {}, "history": {}, "static": {}}
return {"engine": engine, "live": live, "requests": hist[::-1], "hardware": tel["now"], "hardware_static":
tel["static"], "history": tel["history"], "time": now}
def prepare(self, messages, tools, kwargs, max_new=None):
"""-> (ids, thinking, max_new). An unset or non-positive max_new (some clients send -1) means "unlimited":
the rest of the context."""
prompt = self.template.render(messages, tools=tools, **kwargs)
ids = self.tok.encode(prompt, parse_special=True)
self.embeddings.path = None
@@ -352,10 +461,16 @@ class Service:
for path, _ in encoded:
f.write(path.read_bytes())
self.embeddings.path = combined
if len(ids) + max_new > self.engine.max_context:
room = self.engine.max_context - CTX_SLACK - len(ids)
if max_new is None or max_new <= 0:
if room < 1:
raise ValueError(f"prompt ({len(ids)} tokens) leaves no room to answer in the context "
f"({self.engine.max_context}); requests are never truncated")
max_new = room
elif max_new > room:
raise ValueError(f"prompt ({len(ids)} tokens) + max tokens ({max_new}) exceeds the context "
f"({self.engine.max_context}); requests are never truncated")
return ids, kwargs.get("enable_thinking", True) is not False
return ids, kwargs.get("enable_thinking", True) is not False, max_new
def _note(self, n, evs):
with self.status_lock:
@@ -392,6 +507,9 @@ class Service:
def run(self, ids, thinking, tools, max_new, sampling, cancel) -> Iterator[tuple[str, object]]:
"""Yields ("event", Event) as text arrives, then ("done", {"finish": .., "completion_tokens": ..})."""
if self.sampling_defaults: # config defaults; the request's own fields win (explicit 0 stays greedy)
req_values = {k: v for k, v in (sampling or {}).items() if v is not None}
sampling = {**self.sampling_defaults, **req_values}
parser = OutputParser(thinking=thinking, tools=tools, stream_tools=True)
detok, n, finish = Detokenizer(self.tok), 0, "length"
raw_ids = [] # every generated id (STRATA_DEBUG: dump raw model text)
@@ -438,6 +556,14 @@ class Service:
Path(emb).unlink(missing_ok=True)
with self.status_lock:
if self.status.get("busy"):
last = dict(getattr(self.engine, "last", {}) or {})
started = self.status.get("started", time.time())
self.history.append({
"time": started, "duration_s": round(time.time() - started, 1), "finish": finish,
"prompt_tokens": len(ids), "reused": last.get("reused"), "output_tokens": n,
"prompt_ms": last.get("prompt_ms"), "decode_ms": last.get("decode_ms"),
"decode_tok_s": round(last["generated"] / (last["decode_ms"] / 1000), 1)
if n and last.get("generated") and last.get("decode_ms") else None})
now = time.time()
el = now - self.status.get("started", now)
ft = self.status.get("first_token")
@@ -654,8 +780,45 @@ def make_handler(svc: Service):
def do_GET(self):
path = self.path.split("?")[0].rstrip("/")
if path.startswith("/fonts/"):
# the web app's font (Outfit, OFL: serve/web/fonts); the page falls back to the system font
name = path[len("/fonts/"):]
f = ROOT / "serve" / "web" / "fonts" / name
if "/" in name or "\\" in name or not name.endswith(".woff2") or not f.is_file():
self._json(404, {"error": {"message": "not found"}})
return
body = f.read_bytes()
self.send_response(200)
self.send_header("Content-Type", "font/woff2")
self.send_header("Cache-Control", "max-age=86400")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/web/"):
# the web app's own files (serve/web): styles, script, icon sprite - same origin, no CDN
name = path[len("/web/"):]
types = {".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".svg": "image/svg+xml"}
f = ROOT / "serve" / "web" / name
ext = os.path.splitext(name)[1]
if "/" in name or "\\" in name or ext not in types or not f.is_file():
self._json(404, {"error": {"message": "not found"}})
return
body = f.read_bytes()
self.send_response(200)
self.send_header("Content-Type", types[ext])
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/metrics":
if self._authorized():
self._json(200, svc.metrics())
return
if path == "":
body = (ROOT / "serve" / "index.html").read_bytes()
body = (ROOT / "serve" / "web" / "index.html").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
@@ -703,10 +866,8 @@ def make_handler(svc: Service):
def _openai(self, req):
messages, tools, kw = openai_to_messages(req)
max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 1024)
if max_new <= 0: # a non-positive budget (some clients send -1) means "unset"
max_new = 1024
ids, thinking = svc.prepare(messages, tools, kw, max_new)
max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 0) # 0/-1: the rest of the context
ids, thinking, max_new = svc.prepare(messages, tools, kw, max_new)
_debug_req("openai", req, messages, tools, max_new, thinking, len(ids))
cancel = threading.Event()
chunks = openai_chunks(svc, req, ids, thinking, tools, max_new, cancel)
@@ -727,10 +888,8 @@ def make_handler(svc: Service):
def _anthropic(self, req):
messages, tools, kw = anthropic_to_messages(req)
max_new = int(req.get("max_tokens") or 1024)
if max_new <= 0: # a non-positive budget (some clients send -1) means "unset"
max_new = 1024
ids, thinking = svc.prepare(messages, tools, kw, max_new)
max_new = int(req.get("max_tokens") or 0) # 0/-1: the rest of the context
ids, thinking, max_new = svc.prepare(messages, tools, kw, max_new)
_debug_req("anthropic", req, messages, tools, max_new, thinking, len(ids))
cancel = threading.Event()
events = anthropic_events(svc, req, ids, thinking, tools, max_new, cancel)
@@ -760,11 +919,65 @@ class Server(ThreadingHTTPServer):
def serve(svc: Service, host="127.0.0.1", port=8095) -> ThreadingHTTPServer:
svc.start_telemetry()
httpd = Server((host, port), make_handler(svc))
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd
def sampling_defaults_from_config(cfg: dict) -> dict:
"""The run config's optional `sampling` block: defaults for the sampling fields a request leaves out, so
a plain client gets configured sampling instead of greedy. Supported: temperature, top_p, top_k, min_p,
presence_penalty, repetition_penalty, frequency_penalty, penalty_last_n, seed. The request's own fields
always win - an explicit temperature=0 still means greedy, a field set to null falls back to the default.
A bad value refuses to start the server (a typo'd config should not quietly change sampling); unknown keys
are named at startup and ignored."""
out = {}
for key, value in (cfg.get("sampling") or {}).items():
if value is None:
continue
number = isinstance(value, (int, float)) and not isinstance(value, bool)
if key == "temperature":
if not number or value < 0:
raise SystemExit(f"[strata] config sampling.temperature={value!r}: expected a number >= 0 (0 = greedy)")
out[key] = float(value)
elif key == "top_p":
if not number or not 0 < value <= 1:
raise SystemExit(f"[strata] config sampling.top_p={value!r}: expected 0 < top_p <= 1")
out[key] = float(value)
elif key == "min_p":
if not number or not 0 <= value <= 1:
raise SystemExit(f"[strata] config sampling.min_p={value!r}: expected 0 <= min_p <= 1")
out[key] = float(value)
elif key == "top_k":
if not number or value != int(value) or not 1 <= value <= 64:
raise SystemExit(f"[strata] config sampling.top_k={value!r}: the sampled path takes an integer 1..64")
out[key] = int(value)
elif key == "presence_penalty":
if not number or value < 0:
raise SystemExit(f"[strata] config sampling.presence_penalty={value!r}: expected a number >= 0")
out[key] = float(value)
elif key == "frequency_penalty":
if not number or value < 0:
raise SystemExit(f"[strata] config sampling.frequency_penalty={value!r}: expected a number >= 0")
out[key] = float(value)
elif key == "repetition_penalty":
if not number or value <= 0:
raise SystemExit(f"[strata] config sampling.repetition_penalty={value!r}: expected a number > 0 (1 = off)")
out[key] = float(value)
elif key == "penalty_last_n":
if not number or value != int(value) or value < 0:
raise SystemExit(f"[strata] config sampling.penalty_last_n={value!r}: expected a non-negative integer")
out[key] = int(value)
elif key == "seed":
if not number or value != int(value) or value <= 0:
raise SystemExit(f"[strata] config sampling.seed={value!r}: expected a positive integer")
out[key] = int(value)
else:
print(f"[strata] config sampling.{key}={value!r}: unknown key, ignored", flush=True)
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--engine", choices=["mock", "strata"], default="mock")
@@ -805,6 +1018,10 @@ def main() -> int:
ap.error("--engine strata needs --config")
vision = None
env = child_env(cfg)
sampling_defaults = sampling_defaults_from_config(cfg)
if sampling_defaults:
pretty = ", ".join(f"{k}={v}" for k, v in sampling_defaults.items())
print(f"[strata] sampling defaults from the config: {pretty}", flush=True)
if cfg.get("vision"):
print("loading the vision encoder ...", flush=True)
vision = Vision(cfg["vision"], log=open(cfg["log"], "a", encoding="utf-8") if cfg.get("log") else None,
@@ -812,11 +1029,12 @@ def main() -> int:
print("loading the model (the first start takes a minute or two) ...", flush=True)
engine = StrataEngine(cfg["exe"], cfg["args"], cwd=cfg.get("cwd"), log=cfg.get("log"), env=env)
else:
engine, vision = MockEngine(tok, a.script), None
engine, vision, sampling_defaults = MockEngine(tok, a.script), None, {}
# the model's own chat template (exported with its tokenizer), else the original model's
tpl = tpath / "chat_template.jinja"
svc = Service(engine, tok, ChatTemplate(tpl if tpl.exists() else ROOT / "serve/chat_template.jinja"),
model_name=cfg.get("model_name", "qwen3.8-flash-next"), vision=vision)
model_name=cfg.get("model_name", "qwen3.8-flash-next"), vision=vision,
sampling_defaults=sampling_defaults)
svc.api_key = a.api_key or cfg.get("api_key", "")
httpd = serve(svc, host=a.host, port=a.port)
print(f"ready: http://{a.host}:{a.port}/v1 (OpenAI: /v1/chat/completions, Anthropic: /v1/messages, "
+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)}
+194
View File
@@ -0,0 +1,194 @@
"""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, 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 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()
+140
View File
@@ -0,0 +1,140 @@
/* 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; }
.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; }
/* ---------------------------------------------------------------- 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); }
/* ---------------------------------------------------------------- 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); }
}
+688
View File
@@ -0,0 +1,688 @@
// 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").hidden = !health.images;
$("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;
async function poll() {
try {
const r = await fetch("/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 || []);
if (tab === "about") renderAbout(eng, hw, st);
}
function renderMonitor(live, hw, st, eng, h, last, requests) {
// 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, 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"});
return `<tr><td>${esc(t)}</td><td><span class="st-badge ${cls}">${esc(text)}</span></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("");
}
}
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("");
}
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"],
]);
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);
});
// ------------------------------------------------------------------ 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};
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}))})));
}
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.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);
out.push({role: "user", content: imgs.length ? [{type: "text", text: m.text},
...imgs.map((i) => ({type: "image_url", image_url: {url: i.url}}))] : m.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, 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;
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" : ""}`;
} 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: the attach button, or paste into the message box
function addFiles(files) {
for (const f of files) {
if (!f.type.startsWith("image/")) 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({name: f.name || "pasted image", url: r.result}); renderAttachments(); };
r.readAsDataURL(f);
}
}
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("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();
$("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();
}
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));
outputs();
}
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-reset").onclick = () => loadDrawer(DEFAULTS);
$("s-apply").onclick = () => {
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"};
store.set("sampling", settings);
openDrawer(false);
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.
+205
View File
@@ -0,0 +1,205 @@
<!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 picture" aria-label="Attach a picture" hidden>
<svg class="st-icon"><use href="/web/sprite.svg#i-attach"/></svg></button>
<input type="file" id="file" accept="image/*" 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">
<span class="card-title">Recent requests</span>
<div class="table-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>
</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="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;
}
+28 -7
View File
@@ -54,8 +54,8 @@ 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, 6) # KV streaming (--kv-resident), v0.1.5; its drafter fallback, v0.1.6
PY_PACKAGES = ["numpy", "jinja2", "regex", "pyyaml", "tqdm", "requests", "cmake", "ninja", "pillow"]
MIN_ENGINE = (0, 1, 8) # --kv q4_0 and the INFO line (the web app's Monitor), v0.1.8
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},
@@ -647,6 +647,9 @@ 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)
@@ -696,7 +699,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 (34+ 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 (Q2_0 / IQ2_XS) needs about {need} GB",
"Strata keeps all of the model's experts in RAM (34-50 GB, whatever the GPU) and the GPU holds a copy "
"of the most-used ones: it needs 48 GB of RAM or more")
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")
@@ -752,6 +762,17 @@ def main() -> int:
"+ 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:
@@ -770,7 +791,7 @@ def main() -> int:
# ---- 3. python packages
step(3, "Python packages")
pip_install(PY_PACKAGES, "numpy, jinja2, regex, pyyaml, tqdm, requests, cmake, ninja, pillow")
pip_install(PY_PACKAGES, "numpy, jinja2, regex, pyyaml, tqdm, requests, cmake, ninja, pillow, psutil")
# ---- 4. the engine
step(4, "the Strata engine")
@@ -862,11 +883,11 @@ def main() -> int:
"--prefill", "2048", "--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 (1.7 GB at 128K, 3.4 GB at 262K), so only when that fits.
kv_ram_gb = ctx * 13728 / 1e9
# 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
if 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")
+34 -14
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"
@@ -170,9 +171,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,
@@ -507,6 +510,7 @@ KvPlan kv_plan(const QsaShapes& s, int64_t max_cells, int64_t ring_cells) {
return p;
}
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;
}
@@ -540,11 +544,16 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
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;
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
if (g_kv_int8) {
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));
@@ -595,7 +604,7 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
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, g_kv_int8) + 4 * 256;
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 ||
@@ -606,7 +615,10 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
}
g_kv_host_bytes += bytes;
Cursor hc{d};
if (g_kv_int8) {
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));
@@ -645,7 +657,10 @@ 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_int8) {
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);
@@ -666,7 +681,8 @@ void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream) {
strata::kernels::QsaAttnPools qsa_attn_pools(const QsaState& st) {
strata::kernels::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; }
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;
}
@@ -674,7 +690,7 @@ 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) {
if (st.kv_mode != 1) return;
strata::kernels::kv_stream_resolve(st.map, qsa_attn_pools(st), st.host, st.kv_int8, ids, steps, n_q, cap,
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);
}
@@ -846,7 +862,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, &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()) {
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);
@@ -880,14 +900,13 @@ int64_t max_blocks = (st.max_cells / s.idx_block) + 2;
}
// 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;
@@ -902,6 +921,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.
+10 -2
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"
@@ -338,7 +339,12 @@ 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, &st_.host);
else
@@ -355,10 +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);
}
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);
@@ -507,7 +515,7 @@ void MtpDrafter::kv_restore(int64_t upto) {
// 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, st_.kv_int8, b0, b1, st_.n_slots, s, cs_);
strata::kernels::kv_ring_restore(qsa_attn_pools(st_), st_.host, qsa_kv_format(st_), b0, b1, st_.n_slots, s, cs_);
cudaStreamSynchronize(cs_);
}
+30 -1
View File
@@ -12,6 +12,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_mmvq.hpp"
#include "strata/kernels/native_qsa.hpp"
@@ -111,6 +112,8 @@ bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState&
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;
@@ -419,10 +422,17 @@ 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, &st.host);
else
@@ -444,6 +454,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;
@@ -459,6 +470,7 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
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,
@@ -594,6 +606,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;
@@ -793,6 +808,20 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
const cudaError_t se = cudaStreamSynchronize(cs_);
if (se != cudaSuccess) { err = std::string("verify: ") + cudaGetErrorString(se); return false; }
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];
VDBG("window done\n");
++windows;
+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
+17 -10
View File
@@ -1,5 +1,6 @@
// 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>
@@ -29,10 +30,15 @@ struct Runs {
int n;
};
Runs runs_of(const QsaAttnPools& slots, const KvHostPools& host, bool int8, const QsaShapes& s) {
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 (int8) {
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;
@@ -183,9 +189,10 @@ __global__ void ring_kernel(int32_t* table, long long n_blocks, long long n_slot
} // namespace
uint64_t kv_block_bytes(const QsaShapes& s, bool int8) {
uint64_t kv_block_bytes(const QsaShapes& s, int fmt) {
const uint64_t rows = (uint64_t) (s.n_head_kv * s.page_size);
return int8 ? rows * (uint64_t) s.head_dim * 2 + rows * (uint64_t) (s.head_dim / KV_Q8_GROUP) * 2 * 2
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;
}
@@ -194,7 +201,7 @@ void kv_stream_reset(const KvStreamMap& m, void* stream) {
check("reset");
}
void kv_stream_resolve(const KvStreamMap& m, const QsaAttnPools& slots, const KvHostPools& host, bool int8,
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;
@@ -204,7 +211,7 @@ void kv_stream_resolve(const KvStreamMap& m, const QsaAttnPools& slots, const Kv
}
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, int8, s));
copy_kernel<<<96, 128, 0, (cudaStream_t) stream>>>(m, runs_of(slots, host, fmt, s));
check("copy");
}
@@ -213,9 +220,9 @@ void kv_ring_table(int32_t* page_table, int64_t n_blocks, int64_t n_slots, void*
check("ring table");
}
void kv_ring_restore(const QsaAttnPools& slots, const KvHostPools& host, bool int8, int64_t b0, int64_t b1,
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, int8, s);
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)
@@ -226,10 +233,10 @@ void kv_ring_restore(const QsaAttnPools& slots, const KvHostPools& host, bool in
}
}
void kv_stage_from_host(const QsaAttnPools& stage, const KvHostPools& host, bool int8, int64_t n_blocks,
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, int8, s);
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)
+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) {
+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;
}
}
+28 -21
View File
@@ -8,7 +8,8 @@
// 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 and FP16 pools both.
// 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"
@@ -48,9 +49,13 @@ template <typename T> T* halloc(size_t n) { // pinned, mapped; returns the dev
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, bool int8, bool host) {
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 (int8) {
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);
@@ -63,14 +68,15 @@ struct Pools { // one K/V pool set of `pages` pages
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.page_table = table;
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, bool int8, const k::KvHostPools* host) {
if (int8) 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);
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);
}
@@ -96,18 +102,19 @@ std::vector<int32_t> selection(int64_t n_kv, int64_t width, std::mt19937& rng) {
return ids;
}
bool run(bool int8) {
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(int8 ? 7 : 11);
std::mt19937 rng(7 + 4 * fmt);
std::normal_distribution<float> nd(0.f, 1.f);
Pools ref, slots, host;
ref.alloc(n_blocks, s, int8, false);
slots.alloc(n_slots, s, int8, false);
host.alloc(n_blocks, s, int8, true);
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);
@@ -143,8 +150,8 @@ bool run(bool int8) {
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, int8, nullptr);
append(slots, m.page_table, step, kc, vc, s, int8, &host.p);
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);
@@ -163,13 +170,13 @@ bool run(bool int8) {
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, int8, ids, steps, n_q, cap, s, 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", int8 ? "int8" : "fp16", (long long) pos, n_q);
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");
@@ -188,7 +195,7 @@ bool run(bool int8) {
}
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",
int8 ? "int8" : "fp16", batches, (unsigned long long) c.lookups, (unsigned long long) c.misses,
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;
@@ -196,10 +203,10 @@ bool run(bool int8) {
{
const int64_t R = 1500, b1 = n_blocks, b0 = b1 - R;
Pools ring;
ring.alloc(R, s, int8, false);
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, int8, b0, b1, R, s, 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
@@ -211,7 +218,7 @@ bool run(bool int8) {
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", int8 ? "int8" : "fp16", ok ? "identical" : "DIFFERS");
std::printf(" %s ring restore: %s\n", name, ok ? "identical" : "DIFFERS");
if (!ok) ++bad;
}
return bad == 0;
@@ -220,8 +227,8 @@ bool run(bool int8) {
int main() {
std::printf("kv_stream_parity: streamed vs resident KV, bitwise\n");
const bool a = run(true), b = run(false);
if (!a || !b) ++g_fail;
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;
}
+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;
+20 -5
View File
@@ -10,6 +10,7 @@
#include "strata/kernels/cpu/expert_layout.hpp"
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/core/layer.hpp"
#include "strata/kernels/qsa_decode_attn.hpp"
#include "strata/kernels/qsa_select.hpp"
@@ -133,7 +134,10 @@ void take_stage(Alloc& o_borrowed, const core::SessionState& ss, const strata::k
own.owned = o_borrowed.owned;
Alloc& o = stage_own() ? own : o_borrowed;
const size_t rows = (size_t) q0.n_pages * s.n_head_kv * s.page_size;
if (q0.kv_int8) {
if (q0.kv_q4) {
st.k_q4 = o.take<uint8_t>(rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), ok);
st.v_q4 = o.take<uint8_t>(rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), ok);
} else if (q0.kv_int8) {
st.k_q = o.take<int8_t>(rows * s.head_dim, ok);
st.v_q = o.take<int8_t>(rows * s.head_dim, ok);
st.k_scale = o.take<uint16_t>(rows * (s.head_dim / 64), ok);
@@ -146,6 +150,7 @@ void take_stage(Alloc& o_borrowed, const core::SessionState& ss, const strata::k
strata::kernels::QsaAttnPools pools_of(const strata::kernels::KvHostPools& h, const int32_t* table) {
strata::kernels::QsaAttnPools p;
p.k_pool = h.k_pool; p.v_pool = h.v_pool; p.k_q = h.k_q; p.v_q = h.v_q; p.k_scale = h.k_scale; p.v_scale = h.v_scale;
p.k_q4 = h.k_q4; p.v_q4 = h.v_q4;
p.page_table = table;
return p;
}
@@ -424,14 +429,23 @@ bool Prefill::run(const int64_t* tokens, int64_t n, int64_t pos0, std::string& e
// chunk's cells go to the host copy, the staging pool, and the VRAM slots of resident blocks
const bool staged = st.kv_mode == 1;
if (staged)
strata::kernels::kv_stage_from_host(pools_of(m.stage, m.ident_table), st.host, st.kv_int8,
strata::kernels::kv_stage_from_host(pools_of(m.stage, m.ident_table), st.host,
core::qsa_kv_format(st),
(p0 + s.page_size - 1) / s.page_size, s, m.cs);
kv_append(m.Kc, m.Vc, T, p0, st.page_table, s.page_size, st.kv_int8 ? nullptr : st.k_pool,
st.kv_int8 ? nullptr : st.v_pool, st.k_q, st.v_q, st.k_scale, st.v_scale, m.cs,
&st.host, staged ? &m.stage : nullptr);
if (st.kv_q4) { // Q4_0 KV (kv_q4.hpp): rotated K and V, the queries below too, the output back
strata::kernels::fwht256_inplace_cuda(m.Kc, T * 2, m.cs);
strata::kernels::fwht256_inplace_cuda(m.Vc, T * 2, m.cs);
strata::kernels::kv_append_q4(st.k_q4, st.v_q4, st.page_table, p0, T, m.Kc, m.Vc, s, m.cs,
&st.host, staged ? &m.stage : nullptr);
} else {
kv_append(m.Kc, m.Vc, T, p0, st.page_table, s.page_size, st.kv_int8 ? nullptr : st.k_pool,
st.kv_int8 ? nullptr : st.v_pool, st.k_q, st.v_q, st.k_scale, st.v_scale, m.cs,
&st.host, staged ? &m.stage : nullptr);
}
split_q(m.Qf, m.q, T, m.cs);
rms_rows(m.q, (const float*) wqn->data, T * 24, 256, 256, EPS, m.cs);
rope(m.q, T, 24, 256, 6144, p0, (float) strata::kernels::qsa_freq_base(), m.cs);
if (st.kv_q4) strata::kernels::fwht256_inplace_cuda(m.q, T * 24, m.cs);
rms_rows(m.q_idx, (const float*) wiqn->data, T * 4, 128, 128, EPS, m.cs);
rope(m.q_idx, T, 4, 128, 512, p0, (float) strata::kernels::qsa_freq_base(), m.cs);
// the indexer appends, token by token; then scores + selection for many queries at once:
@@ -526,6 +540,7 @@ bool Prefill::run(const int64_t* tokens, int64_t n, int64_t pos0, std::string& e
m.steps_dev + t0 * strata::kernels::kStepCount, m.cap, s,
m.attn_scratch, m.attn + t0 * ZV, nb, m.cs);
}
if (st.kv_q4) strata::kernels::fwht256_inplace_cuda(m.attn, T * 24, m.cs);
gate_attn(m.attn, m.Qf, m.attn_h, T, m.cs);
if (!native_proj(m.gemm, wo, m.attn_h, m.bo, T, v.name("attn_output.weight"), err)) return false;
++qsa_index;
+154 -34
View File
@@ -34,6 +34,7 @@
#include "strata/kernels/native_qsa_indexer.hpp"
#include "strata/kernels/native_rope.hpp"
#include "strata/kernels/mrope.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "strata/kernels/qsa.hpp"
#include "strata/core/native_head.hpp"
#include "strata/core/verify.hpp"
@@ -41,6 +42,7 @@
#include "strata/prefill/prefill.hpp"
#include "strata/core/native_dense.hpp"
#include "strata/program/logits_selection.hpp"
#include "strata/spec/draft_policy.hpp"
#include "strata/spec/suffix_drafter.hpp"
#include <cuda_runtime.h>
@@ -250,10 +252,11 @@ struct Options {
/// result) goes through the verify windows, S tokens at a time, instead of the batched prompt path (0 = always
/// the batched path)
int64_t short_read = 64;
/// The suffix drafter: when the text being written repeats an earlier stretch of the context (code edits, quoted
/// input, tool-call JSON) by at least this many tokens, the window is filled with what followed it there instead
/// of the MTP's drafts, up to --spec - 1 of them, as long as the MTP's own first guess agrees. 0 = MTP only.
int suffix_draft = 0;
/// The suffix drafter (prompt lookup): when the text being written repeats an earlier stretch of the context (code
/// edits, quoted input, tool-call JSON) by at least this many tokens, the window may be filled with what followed
/// it there instead of the MTP's drafts, where the MTP's own first guess agrees and the draft policy expects it to
/// pay (strata/spec/draft_policy.hpp). On by default; 0 = MTP only.
int suffix_draft = 3;
/// The MTP's own window cap (0 = --spec): with --spec 6 --mtp-max-t 4 the long windows come from suffix matches.
int mtp_max_t = 0;
};
@@ -275,6 +278,8 @@ void usage() {
" --ple-sync-submit A/B arm: submit table reads on the token thread (default: an I/O thread)\n"
" --kv fp16|int8 KV storage (plan v0.3 P7): int8 codes + fp16 scale per 64 values, half the\n"
" VRAM; default fp16 until gate G-C accepts int8\n"
" --kv q4_0 4-bit K/V after a Hadamard rotation (PR #21): half of int8's memory,\n"
" slightly lower precision (see bench/results/2026-09-27-kv-q4)\n"
" --kv-resident N KV streaming: keep N cells of each QSA layer in VRAM (min 20480) and the\n"
" whole K/V in pinned RAM; the freed VRAM goes to expert slots. 0 (default):\n"
" all of it in VRAM. A context of N cells or fewer is not streamed\n"
@@ -322,7 +327,8 @@ void usage() {
" --turn-token ID --serve: the token that opens a chat turn (default 248045, <|im_start|>)\n"
" --short-read N --serve: read at most N fresh text tokens through the decode windows instead\n"
" of the batched prompt path (default 64, 0 = off)\n"
" --suffix-draft N draft from an earlier repeat of the last N+ tokens of context (0 = MTP only)\n"
" --suffix-draft N prompt lookup: draft from an earlier repeat of the last N+ tokens of context\n"
" when it pays (default 3; 0 = MTP only)\n"
" --mtp-max-t M cap the MTP's windows at M tokens (0 = --spec; longer ones come from suffixes)\n"
" --no-token-graph A/B: two graphs per layer (the host launches each) instead of one per token\n"
" --no-fused-gr A/B: the six-kernel hyper-connection read and a separate write (native)\n"
@@ -722,16 +728,25 @@ int main(int argc, char** argv) {
std::fprintf(stderr, "strata generate: invalid --ple-io/--ple-row-cache/--ple-inflight/--ple-delay-us\n");
return 2;
}
if (o.kv != "fp16" && o.kv != "int8") {
std::fprintf(stderr, "strata generate: --kv must be fp16 or int8\n");
if (o.kv == "q4") o.kv = "q4_0";
if (o.kv != "fp16" && o.kv != "int8" && o.kv != "q4_0") {
std::fprintf(stderr, "strata generate: --kv must be fp16, int8 or q4_0\n");
return 2;
}
strata::core::qsa_set_kv_int8(o.kv == "int8");
strata::core::qsa_set_kv_q4(o.kv == "q4_0"); // PR #21: 4-bit codes after a Hadamard rotation (kv_q4.hpp)
if (o.kv_resident < 0) {
std::fprintf(stderr, "strata generate: --kv-resident must be >= 0\n");
return 2;
}
strata::core::qsa_set_kv_resident(o.kv_resident);
// Prompt lookup (the suffix drafter, on by default): the MTP keeps its --spec windows and a lookup window may be
// up to 2 tokens longer; the draft policy (strata/spec/draft_policy.hpp) takes one only where it pays. Code
// edits +6-11%, ordinary text unchanged (bench/results/2026-09-27-spec). --suffix-draft 0 turns it off.
if (o.suffix_draft > 0 && o.spec >= 2 && o.mtp_max_t == 0) {
o.mtp_max_t = o.spec;
o.spec = std::min(o.spec + 2, 8); // kVerifyMaxT
}
strata::core::layer_set_shared_early(!o.shared_late);
if (!o.native_preset.empty()) {
if (o.no_ple || o.ple_gguf.empty()) {
@@ -1240,19 +1255,16 @@ int main(int argc, char** argv) {
(long long) xcache.slots(), xcache.gib());
mem_mark("opening the expert cache");
xcache.set_per_layer_admission(o.expert_cache_per_layer);
// **ROUND 328: THE HIT PATH IS PROVABLY WRONG, AND THIS SAYS SO OUT LOUD RATHER THAN LETTING IT
// CORRUPT A RUN QUIETLY.** With the cache on, the generated tokens DIVERGE from the cache-off run:
// at 256 global slots (2.97% hits) the first difference is at **token 40**; at 4096 per-layer slots
// (54.4% hits) it is at **token 0**. The cache-off run is deterministic across repeated runs, so
// this is a real fault in `moe_hit_grouped_s2`'s inputs or the fill - not noise. It also explains
// what R4 recorded as "a better profile makes the token worse": more hits means more wrong rows, so
// the payoff is non-monotone BY CONSTRUCTION rather than by any memory-system effect.
// The cache stays opt-in and this warning is not a refusal, because the divergence IS the diagnostic.
// Round 328 warned here that the GPU hit path was wrong (tokens diverged from a cache-off run from
// token 0). That fault was fixed long since (native_expert_parity, expert_parity, the grouped kernels'
// tests), and the warning outlived it (issue #23). What remains is rounding: a GPU expert and the CPU's
// compute the same quantized expert with different float order, so a near-tie can flip. Measured teacher-
// forced on 2,557 tokens (bench/results/2026-09-27-cache-parity): 95-98% same top-1, and perplexity equal
// (on - off = -0.005 +- 0.005 nats). Neither output is more correct than the other.
std::fprintf(stderr,
"strata generate: *** WARNING: --expert-cache is enabled and the GPU hit path is NOT\n"
" CORRECT. The generated tokens diverge from a cache-off run (measured:\n"
" first difference at token 40 at 2.97%% hits, token 0 at 54.4%%). Any\n"
" timing from this run is real; any OUTPUT from it is not. ***\n");
"strata generate: the GPU computes the experts in the cache; it rounds differently from the CPU,\n"
" so a reply can differ slightly from a run without the cache (same quality:\n"
" bench/results/2026-09-27-cache-parity).\n");
if (o.expert_cache_per_layer) {
int64_t lo = 0, hi = 0;
xcache.layer_slot_range(0, lo, hi);
@@ -1991,6 +2003,16 @@ int main(int argc, char** argv) {
return 1;
}
mem_mark("the head and the prompt path");
// the penalty-history buffer: ONE row, the request's last `penalty_last_n` tokens, -1 padded in
// front. Allocated once at the cap; a request without penalties gets a null row and takes the
// byte-for-byte neutral path (no upload, no buffer handed to the sampler).
constexpr int kPenaltyWindowCap = 4096;
int32_t* d_hist = nullptr;
std::vector<int32_t> hist_stage((size_t) kPenaltyWindowCap, -1);
if (cudaMalloc(&d_hist, (size_t) kPenaltyWindowCap * sizeof(int32_t)) != cudaSuccess) {
std::fprintf(stderr, "strata serve: the penalty-history allocation failed\n");
return 1;
}
strata::core::Verifier ver;
strata::core::VerifyHits vh;
vh.d_res = thits.d_res;
@@ -2156,6 +2178,18 @@ int main(int argc, char** argv) {
(long long) (free_b >> 20), free_b < ((size_t) 128 << 20)
? " - LOW: requests may stall; lower --max-context or raise --vram-reserve-mib" : "");
}
// what the server's Monitor tab shows (servers before 0.1.8 skip unknown lines until READY)
{
size_t free_b = 0, total_b = 0;
cudaMemGetInfo(&free_b, &total_b);
std::printf("INFO context=%lld kv=%s kv_resident=%lld expert_slots=%lld expert_cache_mib=%lld spec=%d "
"mtp_max=%d lookup=%d vram_free_mib=%lld\n",
(long long) o.max_context, o.kv.c_str(),
(long long) (g.n_qsa_layers() > 0 && ss.qsa_states[0].kv_mode == 1
? ss.qsa_states[0].n_slots * 4 : 0),
(long long) xcache.slots(), (long long) (xcache.bytes() >> 20), o.spec, o.mtp_max_t,
o.suffix_draft, (long long) (free_b >> 20));
}
std::printf("READY %lld stop\n", (long long) o.max_context); // "stop": this engine honours STOP
std::fflush(stdout);
std::string line;
@@ -2164,6 +2198,7 @@ int main(int argc, char** argv) {
const int S_mtp = o.mtp_max_t > 0 ? std::min(o.mtp_max_t, S) : S; // the MTP's windows; suffixes go up to S
if (S_mtp < S) mtp.set_max_drafts(S_mtp - 1);
strata::spec::SuffixDrafter sfx(std::max(1, o.suffix_draft), 64, (size_t) o.max_context + 4096);
strata::spec::DraftPolicy policy(S); // MTP or lookup window, learned over the whole process
// The vision path (--vision): GENI <max_new> <embeddings file> <id,id,...> carries images. The file is one
// or more strata-vision records (int32 'SVE1', n, nx, ny, n_embd, then n x n_embd floats) in prompt order;
// each image's rows go to its run of <|image_pad|> tokens, whose M-RoPE positions are mtmd's: t = p,
@@ -2182,6 +2217,37 @@ int main(int argc, char** argv) {
}
char* endp = nullptr;
const long long max_new = std::strtoll(line.c_str() + (geni ? 5 : 4), &endp, 10);
// optional sampling keys between max_new and the ids: temperature=F, top_p=F, top_k=N, min_p=F,
// penalty_last_n=N, penalty_repeat=F, penalty_freq=F, penalty_present=F, seed=N (text requests
// only). Absent keys keep today's behavior: greedy, no penalties.
float req_temperature = 0.0f, req_top_p = 1.0f;
int req_top_k = 20; // the sampler's own default; the sampled path REQUIRES top_k in 1..64
unsigned long long req_seed = 0;
float req_min_p = 0.0f, req_penalty_repeat = 1.0f, req_penalty_freq = 0.0f, req_penalty_present = 0.0f;
int req_penalty_last_n = 0;
if (!geni && endp != nullptr) {
for (;;) {
while (*endp == ' ') ++endp;
const char* start = endp;
while (*endp != '\0' && *endp != ' ') ++endp;
if (endp == start) break;
const std::string tok(start, (size_t) (endp - start));
const size_t eq = tok.find('=');
if (eq == std::string::npos) { endp = const_cast<char*>(start); break; }
const std::string key = tok.substr(0, eq);
const float fv = std::strtof(tok.c_str() + eq + 1, nullptr);
if (key == "temperature") req_temperature = fv;
else if (key == "top_p") req_top_p = fv;
else if (key == "top_k") req_top_k = std::atoi(tok.c_str() + eq + 1);
else if (key == "min_p") req_min_p = fv;
else if (key == "penalty_last_n") req_penalty_last_n = std::atoi(tok.c_str() + eq + 1);
else if (key == "penalty_repeat") req_penalty_repeat = fv;
else if (key == "penalty_freq") req_penalty_freq = fv;
else if (key == "penalty_present") req_penalty_present = fv;
else if (key == "seed") req_seed = std::strtoull(tok.c_str() + eq + 1, nullptr, 10);
// unknown keys are skipped: the ids start at the first token without '='
}
}
std::string emb_path;
if (geni && endp != nullptr) {
while (*endp == ' ') ++endp;
@@ -2417,6 +2483,23 @@ int main(int argc, char** argv) {
return true;
};
apply_pending(true);
// per-request sampling for the verify window's head (greedy when temperature is absent)
strata::kernels::SamplerParams req_sp;
req_sp.greedy = req_temperature <= 0.0f;
req_sp.temperature = req_temperature;
req_sp.top_p = req_top_p;
req_sp.top_k = req_top_k;
req_sp.seed = req_seed ? req_seed
: (unsigned long long) std::chrono::steady_clock::now().time_since_epoch().count();
req_sp.min_p = std::clamp(req_min_p, 0.0f, 1.0f);
req_sp.penalty_last_n = std::max(req_penalty_last_n, 0);
req_sp.penalty_repeat = req_penalty_repeat;
req_sp.penalty_freq = req_penalty_freq;
req_sp.penalty_present = req_penalty_present;
req_sp.counter = 0;
ver.set_sampling(req_sp);
const int hist_n = std::min(req_sp.penalty_last_n, kPenaltyWindowCap);
ver.set_history(hist_n > 0 ? d_hist : nullptr, hist_n);
bool cancelled = false;
tr("prompt start", n - 1);
// The prompt is read in two parts when it has a turn boundary past `resume`: up to the last <|im_start|>
@@ -2494,13 +2577,20 @@ int main(int argc, char** argv) {
while (T < S_mtp && dprob[(size_t) T - 1] >= (float) o.spec_min_p) ++T;
}
if (first_window) T = 1;
// a repeat of earlier context proposes more than the MTP is confident about: take it, but only where the
// MTP's own first guess agrees (a repeat the model is not following costs a whole window)
// a repeat of earlier context (prompt lookup) where the MTP's own first guess agrees: the policy takes it
// when its expected tokens per ms, from the measured acceptance and window costs, beat the MTP window's
bool from_sfx = false;
int sfx_match = 0;
if (o.suffix_draft > 0 && !first_window) {
const int k = sfx.propose(S - 1, sbuf.data());
if (k + 1 > T && sbuf[0] == drafts[0]) { T = k + 1; from_sfx = true; }
sfx_match = sfx.last_match();
if (k > 0 && sbuf[0] == drafts[0]) {
const strata::spec::DraftPolicy::Pick pk = policy.choose(T, k, sfx_match);
if (pk.lookup) { T = pk.t; from_sfx = true; }
}
}
const bool timed_round = !first_window;
const Clock::time_point round0 = Clock::now();
if (p + T > o.max_context) break;
window[0] = x;
for (int i = 1; i < T; ++i) window[(size_t) i] = from_sfx ? sbuf[(size_t) i - 1] : drafts[(size_t) i - 1];
@@ -2508,6 +2598,19 @@ int main(int argc, char** argv) {
drive.d.experts = 0;
drive.d.failed = false;
apply_pending(false);
if (hist_n > 0) {
// the tail the penalties count over: the tokens the state has consumed plus the fed-back
// head `x` (it joins `consumed` only after this window commits). Most recent LAST,
// -1 in the unused front slots - the sampler's row layout.
const int64_t avail = (int64_t) consumed.size() + 1;
const int take = (int) std::min<int64_t>(hist_n, avail);
std::fill(hist_stage.begin(), hist_stage.begin() + hist_n, -1);
for (int j = 0; j < take - 1; ++j)
hist_stage[(size_t) (hist_n - take + j)] =
consumed[(size_t) ((int64_t) consumed.size() - (take - 1) + j)];
hist_stage[(size_t) (hist_n - 1)] = (int32_t) x;
cudaMemcpy(d_hist, hist_stage.data(), (size_t) hist_n * sizeof(int32_t), cudaMemcpyHostToDevice);
}
tr("window", p, T);
if (!ver.run(T, window.data(), p, &drive_pool_multi, &drive, outv.data(), err) || drive.d.failed) {
std::printf("ERR %s\n", drive.d.failed && drive.d.fail ? drive.d.fail : err.c_str());
@@ -2550,6 +2653,9 @@ int main(int argc, char** argv) {
std::printf("ERR %s\n", err.c_str());
return 1;
}
if (timed_round && !eos)
policy.observe(from_sfx, T, a, sfx_match,
std::chrono::duration<double, std::milli>(Clock::now() - round0).count());
if (eos) { finish = "stop"; break; }
if (stop_req.load()) { finish = "cancel"; break; }
x = outv[(size_t) a];
@@ -2605,6 +2711,19 @@ int main(int argc, char** argv) {
uint64_t h_ple = hash_dev(ss.ple_hist, z.ple, 1469598103934665603ull);
uint64_t h_tail = 1469598103934665603ull, h_pool = h_tail, h_kv = h_tail, h_stale = h_tail;
const int64_t kvb = qs.head_dim, scb = (qs.head_dim / 64) * 2;
// a state's K/V arrays and their bytes per (cell, head) row: the host copy when it has one
auto kv_arrays = [&](const strata::core::QsaState& st) {
const bool h = st.kv_mode != 0;
std::vector<std::pair<const void*, int64_t>> a;
if (st.kv_q4) {
const int64_t q4b = (int64_t) strata::kernels::kv_q4_bytes_per_head((int) qs.head_dim);
a = {{h ? st.host.k_q4 : st.k_q4, q4b}, {h ? st.host.v_q4 : st.v_q4, q4b}};
} else {
a = {{h ? st.host.k_q : st.k_q, kvb}, {h ? st.host.v_q : st.v_q, kvb},
{h ? st.host.k_scale : st.k_scale, scb}, {h ? st.host.v_scale : st.v_scale, scb}};
}
return a;
};
const int64_t end_cell = std::min<int64_t>(((L + qs.page_size - 1) / qs.page_size) * qs.page_size,
ss.qsa_states[0].max_cells);
for (int64_t i = 0; i < g.n_qsa_layers(); ++i) {
@@ -2612,11 +2731,7 @@ int main(int argc, char** argv) {
h_tail = hash_dev(st.idx_tail, z.tail, h_tail);
h_pool = hash_dev(st.idx_pooled, (size_t) (L / qs.idx_block) * qs.idx_dim * 4, h_pool);
// KV streaming: the host copy is the identity layout and holds every cell
const bool hs = st.kv_mode != 0;
for (const auto& [pool, w] : {std::pair<const void*, int64_t>{hs ? st.host.k_q : st.k_q, kvb},
{hs ? st.host.v_q : st.v_q, kvb},
{hs ? st.host.k_scale : st.k_scale, scb},
{hs ? st.host.v_scale : st.v_scale, scb}}) {
for (const auto& [pool, w] : kv_arrays(st)) {
h_kv = hash_cells(pool, w, 0, L, h_kv);
h_stale = hash_cells(pool, w, L, end_cell, h_stale);
}
@@ -2624,11 +2739,7 @@ int main(int argc, char** argv) {
const strata::core::QsaState& ms = mtp.kv_state();
uint64_t h_mtp = 1469598103934665603ull;
const int64_t mL = std::min<int64_t>(L, ms.max_cells);
const bool mh = ms.kv_mode != 0;
for (const auto& [pool, w] : {std::pair<const void*, int64_t>{mh ? ms.host.k_q : ms.k_q, kvb},
{mh ? ms.host.v_q : ms.v_q, kvb},
{mh ? ms.host.k_scale : ms.k_scale, scb},
{mh ? ms.host.v_scale : ms.v_scale, scb}})
for (const auto& [pool, w] : kv_arrays(ms))
if (pool != nullptr) h_mtp = hash_cells(pool, w, 0, mL, h_mtp);
std::fprintf(stderr, "strata serve: STATE_HASH L=%lld gdn=%016llx ple=%016llx tail=%016llx pooled=%016llx "
"kv=%016llx mtp=%016llx stale=%016llx ple_prev=%d,%d\n", (long long) L,
@@ -3110,6 +3221,7 @@ int main(int argc, char** argv) {
const int S_mtp = o.mtp_max_t > 0 ? std::min(o.mtp_max_t, o.spec) : o.spec;
if (use_mtp && S_mtp < o.spec) mtp.set_max_drafts(S_mtp - 1);
strata::spec::SuffixDrafter sfx(std::max(1, o.suffix_draft), 64, (size_t) o.max_context + 4096);
strata::spec::DraftPolicy policy(o.spec); // MTP or lookup window (see draft_policy.hpp)
std::vector<int32_t> sbuf((size_t) o.spec, 0);
int64_t sfx_windows = 0, sfx_drafts = 0, sfx_ok = 0;
if (o.suffix_draft > 0) {
@@ -3127,10 +3239,16 @@ int main(int argc, char** argv) {
}
if (first_window) T = 1;
bool from_sfx = false;
int sfx_match = 0;
if (o.suffix_draft > 0 && !first_window) {
const int k = sfx.propose(o.spec - 1, sbuf.data());
if (k + 1 > T && (!use_mtp || sbuf[0] == drafts[0])) { T = k + 1; from_sfx = true; }
sfx_match = sfx.last_match();
if (k > 0 && (!use_mtp || sbuf[0] == drafts[0])) {
const strata::spec::DraftPolicy::Pick pk = policy.choose(T, k, sfx_match);
if (pk.lookup) { T = pk.t; from_sfx = true; }
}
}
const bool timed_round = !first_window;
++window_hist[(size_t) T];
if (p + T > o.max_context) {
std::fprintf(stderr, "strata generate: ran out of context at position %lld\n", (long long) p);
@@ -3201,7 +3319,9 @@ int main(int argc, char** argv) {
}
x = outv[(size_t) a];
p += a + 1;
total_ms += std::chrono::duration<double, std::milli>(Clock::now() - t0).count();
const double round_ms = std::chrono::duration<double, std::milli>(Clock::now() - t0).count();
total_ms += round_ms;
if (timed_round) policy.observe(from_sfx, T, a, sfx_match, round_ms);
if (rounds % 64 == 0)
std::fprintf(stderr, "strata generate: position %lld, %lld tokens, %lld rounds\n", (long long) p,
(long long) produced.size(), (long long) rounds);
+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)
-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
+2 -1
View File
@@ -41,7 +41,8 @@ from canonical_xcheck import (MAPPINGS, KV_IQ4NL, open_shard, # noqa
FORMAT_VERSION = 1
ALIGN = 64 # every plane starts on a 64-byte boundary (P1.S7)
STRATA = pathlib.Path(__file__).resolve().parents[3] # the development layout; build takes the shard from --gguf
_HERE = pathlib.Path(__file__).resolve()
STRATA = _HERE.parents[min(3, len(_HERE.parents) - 1)] # the development layout; build takes the shard from --gguf
SHARD2 = STRATA / "Q2_0" / "Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00002-of-00002.gguf"
EXPERT_RE = ("ffn_gate_exps.weight", "ffn_up_exps.weight", "ffn_down_exps.weight")