Compare commits

...
156 Commits
Author SHA1 Message Date
Niko1221 82cf9b83c8 docs: the cluster decode kernels run on sm_90+ (RTX 50), not RTX 40 2026-10-02 18:24:22 +02:00
Niko1221 2aa6ace036 docs: 0.1.36's speed (fused Q2_0 prompt experts, cluster decode kernels) and their switches 2026-10-02 18:24:15 +02:00
Niko1221 fc21e54214 Merge the 0.1.36 speed work: fused Q2_0 prompt experts on by default (STRATA_PF_FUSED=0 off), the native IQ packs' fused kernels opt-in (=1), cluster decode kernels; the fused test waits for its uploads
The ring rule: 1024 slots for the Q2_0 pack's fused experts (P3), 512 for native packs with fused layers.
Quality (teacher-forced vs the FP16 path, Q2_0): fused as close as MMQ at 8K, closer at 32K (phaseA-tests s18-tf).
2026-10-02 16:31:42 +02:00
Niko1221 9d37a3ad5f Merge branch 'f-136-fused' into rel-0.1.36 2026-10-02 16:30:40 +02:00
Niko1221 60445b58c0 #136: the fused int8 prompt experts for the native IQ packs (gate/up IQ2_XXS IQ2_XS IQ2_S IQ3_XXS IQ3_S IQ4_XS, down Q2_0 IQ4_NL; opt-in STRATA_PF_FUSED=1)
src/prefill/moe_fused_iq.cu: the Q2_0 path's design (moe_fused.cu: the grouping on the GPU, a pointer per expert -
cache slot or ring slot, no gather -, SwiGLU and H's int8 rounding in the gate/up epilogue) for the raw GGUF blobs of
the native packs.  The load stage decodes the i-quant blocks to int8 in shared memory (llama.cpp's codebooks and sign
tables, held in shared memory; a 32-value sub-block per thread per 64 values of K, read one stage ahead with 16-bit
loads and two 256-value blocks ahead into L2), with each sub-block's scale (per 16 values for IQ2_XS / IQ2_S, which
multiply at m16n8k16).  The activations are int8 per 32 values in natural order (quantize_act_native); fragments by
ldmatrix; the magic bias 1.5 * 2^23 enters as the mma's C, so a dot becomes a float with one add.  A work item is 256
weight rows x 64 routed rows, or 128 x 128 when the experts have 56-112 rows each (STRATA_PF_FUSED_TILE=64|128).

Wiring (prefill.cpp): a native layer takes these kernels when both its formats are covered, else MMQ (IQ1_M keeps the
FP16 path); the 512-slot ring of the fused path now also for a native pack with such layers.  sm_75, HIP and the
default (no variable) are unchanged: on IQ2_XS, IQ3_XXS and IQ3_S the default output is 0.1.35's (256/256 greedy
tokens, 4K and 32K).

tests/cuda/prefill_fused_iq_test (exit 77 below sm_80), RTX 5070, 256 tokens x 64 experts against a double-precision
reference from ggml's to_float, rel RMS / worst row - MMQ and fused equal to four digits: IQ2_S/Q2_0 1.489e-2 /
2.93e-2 (fused vs MMQ 2.6e-4), IQ2_XXS/Q2_0 1.464e-2 / 2.96e-2 (1.9e-4), IQ2_XS/IQ4_NL 1.465e-2 / 2.41e-2 (3.5e-5),
IQ3_XXS/IQ4_NL 1.458e-2 / 2.73e-2 (1.6e-4), IQ3_S/IQ4_NL 1.470e-2 / 2.65e-2 (6.5e-5), IQ4_XS/Q2_0 1.446e-2 /
2.72e-2 (4.4e-5); both tile shapes.  One layer (512 experts, top 10), ms, MMQ path -> fused at 2048 / 3584 / 8192
tokens: IQ2_S 10.5 -> 5.2, 12.5 -> 9.3, 21.4 -> 17.5; IQ2_XXS 8.8 -> 4.2, 10.7 -> 7.6, 17.9 -> 13.6; IQ3_S 10.6 -> 5.6,
12.3 -> 8.8, 19.8 -> 18.4; IQ3_XXS 10.2 -> 5.1, 11.9 -> 8.3, 19.1 -> 16.5.  MMQ's products alone (no gathers) at
3584 / 8192: IQ2_S 8.9 / 17.9, IQ3_S 8.1 / 15.6: the kernels are about even with MMQ's, the gain is the path around them.
(The test syncs the device after its uploads: a cudaMemcpy from pageable memory on the legacy stream can still be
queued on Windows while a non-blocking stream runs - the grouping then read zeroed ids, every pair on expert 0.
prefill_fused_moe_test's timing part has the same pattern.)

End to end, RTX 5070, speed_135.py's settings, greedy, A (MMQ) / B (STRATA_PF_FUSED=1) alternating, 2 pairs, prompt
tok/s: IQ2_XS 4K 1,228 -> 1,378 (+12%; a cold first A run of 590 left out), 32K 2,107 -> 2,170 (+3.0%); IQ3_XXS 4K
1,001 -> 1,020 (+1.8%), 32K 1,745 -> 1,739 (-0.4%); IQ3_S 4K 927 -> 925 (-0.2%), 32K 1,382 -> 1,362 (-1.4%).  The IQ3
packs' prompts here wait on the host copies of their unpinned experts (IQ3_XXS 4K: 5,062 of 22,573 streamed, ~3.0 s
of 3.4 s), so faster kernels change little.  Each arm repeats itself (256/256; IQ3_XXS 32K B and IQ3_S 32K A split at
162 / 223, the decode's timing); A vs B greedy agreement, identical prefix of 256: IQ2_XS 204 / 88, IQ3_XXS 182 / 89,
IQ3_S 31 / 256.
2026-10-02 16:25:57 +02:00
Niko1221 d05d26e9c3 Decode on thread-block clusters (sm_90+): the QSA top-k and the greedy argmax, the same ids and tokens
- qsa_block_topk: calls of up to 16 queries (decode windows) run on a cluster of 8 CTAs per query
  (block_topk_cluster_kernel): keys in shared memory, digit histograms pushed through distributed shared memory,
  one cluster barrier per radix pass, a parallel digit search, cluster-wide offsets for the ordered emit. The
  threshold and the cut at it are the one-CTA kernels' (same tie rule, NaN keys 0). STRATA_QSA_CLUSTER=0: off.
  RTX 5070 per call: 21.9 -> 15.6 us at 32K, 58 -> 18 at 128K, 200 -> 22 at 262K.
- sample_tokens greedy without penalties: a cluster of 8 CTAs per row (sampler_greedy_cluster_kernel), four loads
  in flight per thread, results merged in CTA 0 (lowest index on ties, NaN never taken, 0 when none).
  STRATA_ARGMAX_MULTI=0: off. 39.6 -> 5.9 us per call at 248,320 logits.
- Both launch through cudaLaunchKernelEx with the cluster attribute (captured in the decode graphs) only where
  they run: CUDA, compute capability 9+ (STRATA_EMULATE_CC respected), the kernel's own PTX/SASS at sm_90+ (an
  older build's JIT copy is a trap), room for one cluster - checked once per device. HIP and sm_75/86/89 keep the
  one-CTA kernels.
- decode_cluster_parity (ctest; skips below sm_90): bitwise vs the one-CTA dispatch and the reference kernel over
  434 top-k cases (contexts 7..262,144, every tail weight, 1-16 queries, ties / NaN / +-inf / -0 / denormal / all
  NaN) and 216 argmax cases, plus a CUDA graph capturing both (first calls inside the capture); --bench timings.
2026-10-02 14:25:26 +02:00
Niko1221 34d4178952 #136: the fused experts take a 1024-slot streamed ring by default (two layers' experts)
With P3's smaller buffers the 1024-slot ring fits beside an 8192-token chunk at 32K (borrowed 3,153 of 3,690 slots,
under the 90% cap).  RTX 5070, Q2_0 (cfg-q2rel), --prefill auto, prompt tok/s at 512 / 1024 slots (2 alternating
pairs): 4K 1,512 / 1,605, 32K 2,485 / 2,660, 128K (KV streaming) 2,346 / 2,392 - there the chunk falls from 8192 to
6144 and is still faster.  Against MMQ (one more pair on this build): 4K 1,299 -> 1,609 (+23.9%), 32K 2,187 -> 2,656
(+21.5%), 128K 2,128 -> 2,454 (+15.3%).  The ring does not change the numbers (the same output as at 512 slots).
Without STRATA_PF_FUSED nothing changes (384 / 96 slots, cap 512).
2026-10-02 14:04:59 +02:00
Niko1221 30e5ba4a31 #136 P3: with the fused experts the prompt path's MoE buffers hold only what the fused kernels need
In a layout whose chunks run the fused experts (STRATA_PF_FUSED=1, the Q2_0 pack, a streamed chunk) GU holds the
grouping tables, H the int8 H rows and Xq the per-token int8 activations, and Hq nothing - instead of MMQ's FP32 GU / H
and per-slot q8_1 rows: ~100 KB a token less in the MoE set, which then no longer sets the shared region's size (the
GDN set does).  A last chunk below stream_all_min() still runs MMQ in the same buffers, so each keeps MMQ's size for
stream_all_min() - 1 tokens.  The ring may grow to 1024 slots with the fused experts (STRATA_PREFILL_RING; the default
stays 512).  Without STRATA_PF_FUSED nothing changes: the same buffers, ring cap (512) and events.

The fused walk now counts the experts it reads from the cache in experts_resident (it stayed 0): every resident expert
of the layer, routed or not - at a streamed chunk's size nearly all are.

RTX 5070, Q2_0 (cfg-q2rel), --prefill auto, 2 alternating pairs, prompt tok/s (borrowed slots):
  4K:   MMQ 1,305 (1621), fused 1,517 (1749), fused + P3 1,512 (1534), + 1024-slot ring 1,605 (2046)
  32K:  MMQ 2,177 (3071), fused 2,453 (3199), fused + P3 2,485 (2641), + 1024-slot ring 2,660 (3153)
  128K (--max-context 131072 --kv-resident 32768): MMQ 2,108 (chunk 8192), fused 2,140 (6144), fused + P3 2,346
        (8192), + 1024-slot ring 2,392 (6144)
A 16K chunk still does not fit the 12 GB card: at 8192 tokens the loan is 2,641 of 3,690 slots, ~2.1K of them per
8K tokens.
2026-10-02 13:43:19 +02:00
Niko1221 6f66917530 #136: the fused experts take a 512-slot streamed ring (a whole layer's experts) by default
The fused path launches on batches of a layer's streamed experts, so with the 384-slot ring a layer's last batch
waited for slots its own first batch frees.  With STRATA_PF_FUSED=1 (and the canonical Q2_0 layout) the pinned-RAM
ring is 512 slots; without it nothing changes (384 / 96 as before; STRATA_PREFILL_RING still overrides).

RTX 5070, Q2_0 (cfg-q2rel), greedy, 3 alternating pairs, prompt tok/s: 4K 1,275 -> 1,516 (+18.9%), 32K 2,176 ->
2,422 (+11.3%); at 384 slots the fused path gave +4-7% / +3%.  1024 slots: 4K +24-27%, but the 32K chunk falls
from 8192 to 6144 (+6%).  The default path's output is identical to 0.1.35's on both prompts.
2026-10-02 12:43:01 +02:00
Niko1221 47de4a4d83 #136: fused int8 tensor-core experts for the Q2_0 pack's prompt path, grouped on the GPU (opt-in STRATA_PF_FUSED=1)
Strata's own kernels (src/prefill/moe_fused.cu): the layer's input rounded to int8 once per token (a scale per 32
values), the (token, k) pairs grouped by expert on the GPU (counts, offsets, a 64-row tile list - no host sync),
then per batch of up to 128 experts one gate/up launch (mma.sync m16n8k32 s8, the 2-bit codes unpacked in
registers, Q2_0's offset folded into the activation blocks' sums, SwiGLU and H's int8 rounding in the epilogue)
and one down launch into the per-slot rows the existing combine reads.  Experts are read where they are (cache
slot or streamed ring slot, a pointer per expert): no gather.

Used only on the streamed walk (chunks from stream_all_min()) of the canonical Q2_0 layout on sm_80+ CUDA, with
STRATA_PF_FUSED=1; it reuses the MMQ buffers (Xq, H, GU).  Everything else - the default, native/IQ packs, small
chunks, sm_75, HIP - runs the MMQ path unchanged (the old block is only wrapped in the else branch).

tests/cuda/prefill_fused_moe_test (exit 77 below sm_80), RTX 5070: vs a double-precision reference, MMQ rel RMS
1.144e-2 (worst row 4.15e-2), fused 1.144e-2 (4.15e-2); fused vs MMQ 9.0e-5.  One layer at 2048 tokens x top 10
over 512 experts: MMQ path 11.12 ms, fused 3.13 ms.
2026-10-02 12:05:46 +02:00
Niko1221 30875ed297 Engine 0.1.36: a cancelled prompt logged as read so far (#471), the draft-head hint (#474), UPDATE.bat (#475), --expert-profile-save (#477); setup requires it 2026-10-02 11:17:13 +02:00
Niko1221 31da8f5dca Opt-in: keep what the expert cache learned across restarts (--expert-profile-save, #477)
`--expert-profile-save P` (--serve) writes the adaptive tier's learning as a profile in tools/make_profile.py's
format, byte for byte (read_expert_profile reads it back): every pair ranked - the experts resident in VRAM now
first, then the routing counted since the start (the decayed usage summed at each adapt step, before its decay),
then the profile the engine started from. On QUIT / stdin EOF and every --expert-profile-save-every minutes
(default 10, 0 = exit only) between requests, through <P>.tmp and a rename. Without the flag nothing is counted or
written: `heat` stays empty and every added loop is over it.

Server: "expert_profile_save" (and "expert_profile_save_every") in the config pass the flags, and the next start
begins from the saved file instead of the config's --expert-profile when its header matches that profile's
layers x experts. tests/core/expert_profile_save_test.cpp (CPU only) checks the order and the bytes.
2026-10-02 11:16:16 +02:00
Niko1221 4ba7c94d4d UPDATE.bat / update.sh: update Strata without starting the model (#475)
In a git clone they run `git pull --ff-only` (a zip copy is told to download the new zip), then `setup.py --update`:
what a plain start does to an install before it starts the model - the Python packages, the ready-made engine when
MIN_ENGINE is newer (a compiled one when its source changed), each model's config upgrades and its draft subset, the
Windows AMD runtime - and nothing else: no question, no model files, no start. Both scripts run from one parsed
block, since the pull can rewrite the running script. Documented in docs/INSTALL.md (Updating) and the README.
2026-10-02 11:06:51 +02:00
Niko1221 c472ff04c8 "The draft head does not fit" says what to change (#474)
Engine: when the MTP draft head's subset does not fit, it prints what the head needs, the VRAM free, and the smaller
shipped subsets (--draft-vocab cyrillic / en, their MiB from the head's real row size) with how to pick one
(START-HERE.bat / ./setup.sh --draft-vocab en, kept in the model's config). Text only, on the failure path.
Server: a start that ended that way carries the engine's hint (or the same advice for an older engine) in its
"the engine exited before it was ready" error. Setup: a card under 14 GB with the default subset gets a note
recommending --draft-vocab en; nothing is changed, an explicit or saved choice gets no note, and the golden
configs are unchanged.
2026-10-02 11:04:05 +02:00
Niko1221 512ae42f7c A prompt cancelled mid-read is logged and recorded with the tokens it read (#471)
The engine's `strata serve: prompt ...` line now says "= 0 reused + 12288 of 98179 read in ... (cancelled)" with the
rate from the tokens read, and DONE gets a 15th field: the prompt tokens actually read. The server's /metrics history
and totals count reused + read for a cancelled read (prompt_total keeps the whole prompt); an older engine's DONE line
(no such field) is read as before.
2026-10-02 11:01:07 +02:00
Niko1221 5b734e427c Engine 0.1.35: Windows AMD uses its bundled HIP runtime (#468 #461), the low-RAM resident mode on Windows 32 GB PCs (#467), fixes #460 #459 #446 #447 #457 #448 #444; setup requires it 2026-10-02 09:44:21 +02:00
Niko1221 82d7e82926 Merge branch 'f-135-small' into rel-0.1.35 2026-10-02 09:41:57 +02:00
Niko1221 772c9330e9 Merge branch 'f-135-eng' into rel-0.1.35 2026-10-02 09:41:57 +02:00
Niko1221 6f8536aed1 docs: the Coder is weaker outside code, Chinese/CJK text included; the unpruned sizes for that (#438)
README and docs/MODELS.md said only "Weaker outside coding". #438 measured Chinese answers wrong or looping on the
Coder where English was fine; both now say plainly that this includes Chinese and other CJK text and point to the
sizes that keep every expert (Q2_0, IQ2_XS, IQ3_S).
2026-10-02 09:39:14 +02:00
Niko1221 6cc16bee09 setup (--gguf-dir): GGUFs Strata cannot run are named as such, with the --family/--model to use (#444)
With Unsloth's UD-IQ3_XXS or UD-Q2_K_XL in the folder, setup either said only "has no IQ3_XXS model file"
(--family unsloth --model IQ3_XXS) or took the UD-IQ3_XXS file for the GSQ-RCO IQ3_XXS by the size in its name.
Now:
- a chosen shard whose name is a quantization setup has no size for (UD-IQ3_XXS, UD-Q2_K_XL, a K-quant) stops
  with "<file> is UD-IQ3_XXS, a GGUF Strata cannot run" and what Strata runs: ISTA-DASLab's GSQ-RCO files (and
  Swift 1.5's and the Coder's) and Unsloth's UD-Q4_K_XL only;
- a folder without the chosen file but with other GGUFs lists the ones Strata cannot use and the --family/--model
  of the ones it can (an empty folder keeps check_shards' "missing");
- a size the family does not have names the families that do (--family qwen --model IQ3_XXS), and with
  --gguf-dir also what Strata runs.
2026-10-02 09:38:48 +02:00
Niko1221 95f2860231 /metrics: each request's speculative draft counts, and their sums in the totals (#457)
The server already read drafts_offered / drafts_accepted from the engine's DONE line but only returned them in a
response's timings (draft_n / draft_n_accepted). Each /metrics request entry now carries both (null when the
engine did not report them) and the totals keep their running sums since the server started, so a dashboard such
as llm-visuals can show the acceptance. Additive: no existing field changes.
2026-10-02 09:36:41 +02:00
Niko1221 22ee8d0e9a serve, setup: name the small split card that caps the prompt chunk; setup recommends the big card alone (#448)
- engine (layer splits only): when a stage's cache cannot fund the chunk CUDA0 alone would choose, a WARNING
  names it (CUDA index, name, cache slots, the slots the chunk borrows) and points at the helper cache
  (--expert-cache-device1, docs/SECOND_GPU.md).  The chunk choice itself is unchanged: fits() is split into
  fits_one() with the same tests; one GPU never reaches the new log.
- setup: a pair whose second card is under 12 GB beside a first card of 12 GB or more is still offered, but
  the first card alone is recommended (the default with --yes and Enter; offer_together asks "n" by default),
  with a note.  A named --gpus pair is kept.  Equal pairs (2x 8/16/24 GB) are unchanged.
- The automatic drop of the small card from an auto split is not done: stages load their weights before the
  boundary search and the exact prompt-buffer bytes need the sessions made after it.
2026-10-02 09:35:59 +02:00
Niko1221 0e4c3e8727 calibrate: the engine's arguments come from the server's engine_args; a failed tuning says why (#447)
tools/calibrate.py read any "gpu" list as a layer split and added --layer-split auto, so a one-card config with a
helper card for the expert tier ("gpu": [0] + --expert-cache-device1) was refused by the engine before it read a
weight, a "0,2" string never got its split, and split_skip_if_fits was ignored. The tuner now builds the arguments
with serve.server.engine_args, the same as a normal start.

When the tuning fails, setup prints the engine's own last error line from the log (only lines written during the
tuning), and says the failure again where it is not lost: before --calibrate starts the model, and in the
"All set." summary of an install that tuned.
2026-10-02 09:35:26 +02:00
Niko1221 cd49a498c8 setup (--backend hip): a system ROCm without the HIP development files falls back to the wheels (#446)
rocm_root took /opt/rocm when bin/hipcc and lib/libhipblas.so* were there and the version was 7.0 or newer. A
runtime-only ROCm 7 (no -dev packages) has both, and the engine's cmake configure then failed on the missing
hip-lang package. The system ROCm now also needs lib/cmake/hip-lang/hip-lang-config.cmake (lib, lib64 or the
multiarch lib folder, where CMake looks) and include/hip/hip_runtime.h; without them setup warns, naming what is
missing, and takes the TheRock wheels path, as it already does for a ROCm older than 7.0.
2026-10-02 09:33:17 +02:00
Niko1221 ce2d83b62d setup: an earlier config that does not parse is skipped, not a crash; configs are written atomically (#459)
A fresh copy of Strata set itself up like the newest strata-*.json of another Strata folder and crashed with
JSONDecodeError when that file was empty or cut off. previous_config now takes the newest one that parses as a JSON
object; each one that does not gets a one-line warning naming it, and with none the copy is set up as a fresh
install. Every place setup writes a run config now goes through write_config (a .tmp file, then os.replace), so a
setup stopped half-way no longer leaves an empty config behind.
2026-10-02 09:31:55 +02:00
Niko1221 42bc3dd9ad engine: the low-RAM resident mode engages on Windows 32 GB PCs (#467)
- Windows: the process working set is trimmed before the resident complement's RAM reading, so the experts
  the GPU cache pre-fill touched through the mapping count as available again (standby list); one line
  prints the reading before and after.
- --resident-experts (soft): when the whole complement does not fit, a second try keeps what fits, by
  profile rank, through the #403 budget path (RAM room minus the 256 MiB margin) instead of falling back
  to mmap entirely; a WARNING names it.  Same file bytes either way, so the answers are unchanged.
- Only the resident mode calls pin_cache_complement: the default and mmap paths are untouched.
2026-10-02 09:31:35 +02:00
Niko1221 a2edcb12f9 Windows AMD: the HIP runtime next to strata.exe, not an AMD driver's System32 copy (#468, #461)
Windows looks for an imported DLL in the exe's folder, then System32, then PATH (where the zip's rocm/bin is): a
driver that installs its own amdhip64_7.dll in System32 won, and the bundled rocBLAS/hipBLAS ran on that runtime - an
access violation on a W7900, hipErrorInvalidDeviceFunction on a 7900 XTX, on the first prompt. setup copies
amdhip64_7.dll and amd_comgr.dll from engine/rocm/bin next to the exes after the unzip, before the device probe and
on every start (so a 0.1.34 install is fixed too); package_windows.py ships them there; the engine and strata-device
log which runtime they loaded. rocBLAS/hipBLASLt stay in rocm/bin (their kernel libraries and .kpack are found from
there).
2026-10-02 09:31:18 +02:00
Niko1221 8896607268 serve: "messages" sent as a JSON string is decoded; a non-object entry is a 400 (#460)
openai_to_messages and anthropic_to_messages iterated req["messages"] directly, so a client that double-encodes
the array as a JSON string had its characters iterated and the request crashed on m.get. The new _object_list
decodes such a string and requires a list of objects; anything else raises ValueError("messages must be a list
of objects"), which the server answers with a 400. A message's tool_calls get the same guard.
2026-10-02 09:30:24 +02:00
Niko1221 eb8bb8bcd9 README, MODELS: the AMD baseline - RX 9070 XT + Ryzen 9 3900X + 47 GB RAM (Q2_0, IQ2_XS, the Coder)
Measured with setup's own install and the README's speed method on the AMD box.
2026-10-02 03:08:05 +02:00
Niko1221 43685766ae docs: byte-identical server repeats also need --pcie-frac 0 (#410, measured) 2026-10-02 00:59:21 +02:00
Niko1221 3997ca29fd docs (AI setup): AMD on Windows - the ready-made engine, the device check before the download 2026-10-02 00:55:01 +02:00
Niko1221 1867a8d6a1 README: pictures on AMD (Linux through the CPU, not on Windows yet) 2026-10-02 00:31:47 +02:00
Niko1221 dd23aa676c docs (AMD): Windows ships in 0.1.34 2026-10-02 00:30:46 +02:00
Niko1221 348ac7ef0e docs (AMD): the router's decode gain as measured by a user too (+1-4% over A/B/A/B), WMMA +29-34% with TheRock 7.10 (#432, jkuepker) 2026-10-02 00:30:25 +02:00
Niko1221 d5871c1af1 prefill: an MMQ product without a tile on some GPU takes the non-MMQ path instead of aborting (#420, qni-live)
llama.cpp's tile choice aborts the process ("J_best=0") when no tile size of a type fits the card's shared memory.
The plan now asks the same question for every visible GPU (the experts' gate+up and down shapes, the draft layer's
Q8_0 projections) and keeps a product with no tile on the existing FP16 path, saying which type, card and limits.
Where every product fits (all cards tested here), the plan is the same as before.
2026-10-02 00:28:48 +02:00
Niko1221 af53393b94 setup: UD-Q4_K_XL on AMD is asked before the download (#429); a resumed download needs room only for the rest (#425)
#429 (jkuepker): the HIP engine has no prompt kernels for its Q4_K/Q5_K experts and it has not been run on AMD -
--check says so, and setup asks (--model UD-Q4_K_XL --yes tries it). #425 (jctaborda): the finished shards and the
.part files already on the disk count against the download's size.
2026-10-02 00:27:13 +02:00
Niko1221 77668b2e3c serve: a request is cancelled within a second of its client hanging up (#430, #431, jkuepker)
A non-streamed request wrote nothing until its end, and a streamed one only a keep-alive per prompt chunk (the
first write after a hang-up usually still succeeds), so a dropped request kept the engine reading the whole prompt
or generating to max_tokens while the next request waited. A watcher per request checks the client's socket for EOF
every 0.5 s and cancels; the engine then stops at its next prompt chunk.
2026-10-02 00:24:20 +02:00
Niko1221 14dbd1be5f Engine 0.1.34: AMD on Windows (a ready-made HIP engine, #247 #325), an MCP server so an AI assistant can set Strata up (#133), a shorter README; setup requires it 2026-10-02 00:13:57 +02:00
Niko1221 3629d22c76 Merge branch 'f-133-docs' into rel-0.1.34
# Conflicts:
#	README.md
#	docs/AMD_HIP.md
#	setup.py
2026-10-02 00:09:58 +02:00
Niko1221 524b091daf Merge branch 'f-133-mcp' into rel-0.1.34 2026-10-02 00:09:12 +02:00
Niko1221 6edbe9d6ed Merge branch 'f-133-winamd' into rel-0.1.34
# Conflicts:
#	serve/test_server.py
2026-10-02 00:09:12 +02:00
Niko1221 8554b6bc53 hip_compat: cudaDevAttrComputeCapabilityMinor (#371's check, the HIP build) 2026-10-01 22:23:10 +02:00
Niko1221 30a0f3269a Engine 0.1.33: a portable image encoder again (#411 #412), setup recommends instead of forcing (#406 #403 #364 #384), fixes #352 #365 #369 #371 #375 #393 #408 #414; setup requires it 2026-10-01 21:17:17 +02:00
Niko1221 decfc96c98 Merge branch 'f-133-choice' into rel-0.1.33 2026-10-01 21:12:04 +02:00
Niko1221 95ade2d5ee docs: the RAM rule recommends 128K, the low-RAM mode on several GPUs, --resident-budget-gib / --kv-streaming (#406 #403 #364 #384) 2026-10-01 21:03:56 +02:00
Niko1221 f0ee8ce54f setup: recommend, never force - longer contexts, more GPUs, a bigger RAM budget are kept with their risk (#406 #403 #364 #384)
The recommended answers stay the defaults, and --yes / Enter write the same config as before
(tools/test_setup_golden.py, five PCs x five sizes).  What a user asks for beyond the recommendation is kept and
its risk said:

- #406: the RAM rule for IQ3_XXS / IQ3_S (experts + KV + 24 GB) is part of the recommended context (the smaller of
  it and the GPU's rule), no longer a cap: an explicit --context, a pick in the list or the earlier install's
  context is kept with a note ("256K with IQ3_S needs ~78 GB of RAM by setup's estimate; this PC has 64. Kept as
  you chose ..."), and the list marks the sizes past it.  The low-RAM mode keeps its KV cache in VRAM, so there
  the context does not count against RAM.
- #364 #384: the low-RAM mode recommends one GPU (the resident variant has no layer split) and asks: 2 = all the
  chosen GPUs with the experts read through the OS file cache (--mmap-experts, layer_split auto; faster in both
  reports, RAM can fill up during long prompts).  An explicit --gpus (or the earlier install's cards) is kept;
  --yes keeps one GPU.  The GPUs' share counts every card.  The one-GPU text no longer sends users to --low-ram off.
  At start, --gpus on a --resident-experts config (and offer_together's "y", whose default is now n for such a
  config) switches it to --mmap-experts with a note instead of an engine refusal.
- S4: --resident-budget-gib N for UD-Q4_K_XL (default: the RAM less 24 GB, as before); more is kept with a note,
  and a given N is not lowered for KV streaming.
- confirm_risk(msg, explicit, yes): one helper for the stops a user may take on - asked with default n, and with
  --yes an explicit flag is the consent (--yes alone keeps the stop): UD-Q4_K_XL under 48 GB (--model), the RAM
  floor (--model), the paging question (--model, besides --low-ram off), a card under 8 GB named with --gpus.
- --kv-streaming on|off|auto (auto: the RAM test, as before); on past the RAM test with a note, never with k8v4 or
  under WSL.

The refusals that stay are the impossible ones: rope none past 262144, disk space, engine version, driver < 580,
no AVX2, a size outside the family.
2026-10-01 21:03:56 +02:00
Niko1221 7349d9112b engine: --resident-experts with a layer split runs as --mmap-experts with a warning (#364 #384)
The low-RAM mode's resident variant has no layer split yet, and the engine refused the pair.  When the residency
comes only from setup's soft --resident-experts (no --resident-cpu-experts, no --resident-budget-gib, no remote
caches), it now prints a WARNING - the experts the GPUs do not hold are read through the OS file cache and RAM may
fill up during long prompts - and continues exactly as --mmap-experts: the resident flags are cleared before
anything reads them, so the source, the placement and the per-stage caches are the mapped mode's (#364's hand-edited
config).  An explicit --resident-cpu-experts, a budget, or remote caches are still refused.

The explicit --expert-cache reduced under a layer split is now a WARNING that names --vram-reserve-mib.
2026-10-01 21:00:49 +02:00
Niko1221 152f7198b9 engine: a RAM budget clamped to the free RAM no longer fails its own safety check (#403)
FileExpertSource::pin_cache_complement read the available RAM twice: the budget was clamped to the first reading
(less the 4 GiB headroom) and the resident-memory check then failed it on a second reading a few MB lower, so a
64 GB PC with setup's --resident-budget-gib 40 could exit at start.  With a budget, the check now reuses the reading
the budget was sized from, and a clamped budget stops 256 MiB under the room.  An unclamped budget holds the same
experts as before (the lend region is off with a budget, so `budget` there is unused).

If the budget still cannot be kept (no RAM reading, an allocation that fails), generate.cpp warns and serves every
expert the GPU does not hold from the files through the OS file cache (--mmap-experts) instead of exiting; the
complement is left unbuilt on every failure path.
2026-10-01 21:00:48 +02:00
Niko1221 41e9ecb016 docs: byte-identical repeats through the server also need --prompt-cache 0 --adapt-swaps 0 (#410) 2026-10-01 20:53:18 +02:00
Niko1221 18a0ec2d47 tests: golden --yes / Enter configs on five PCs (#403 #406 #364 #384)
setup.main() with --yes, and with Enter for every question, on 64 GB + 32 GB, 32 GB + 2x24 GB, 47 GB + 2x16 GB,
96 GB + 16 GB and 128 GB + 24 GB, for the recommended size, Q2_0, IQ3_XXS, IQ3_S and UD-Q4_K_XL: the run config
recorded by this setup (tools/test_setup_golden.json), so the choices that follow cannot change the default.
2026-10-01 20:52:24 +02:00
sergiywith 17ebb94433 gr_parity: compare the split read (STRATA_GR_V3=1) within float rounding
With STRATA_GR_V3=1 "fused GR multi max-T=8 LDS launch and changing graph replay" failed: the test compares the
multi-token read with the single-token kernel bit for bit, and v3 sums `down` in another order (row group x stream
blocks). It also compared `lo`, the default kernels' workspace between down and up, which v3 keeps in shared
memory and never writes. Under v3 the test now checks R_out, rs, inject and mixed within 2e-6 of the largest
reference value and leaves `lo` out; without it, bit for bit as before. Passes both ways.
2026-10-01 20:51:59 +02:00
Niko1221 e878547256 fused_gr: STRATA_GR_V3=1 keeps the default read where it needs the two-half split (Turing; #375, kenh0u)
Opt-in path only; the default read is unchanged.
2026-10-01 20:51:54 +02:00
Niko1221 950c1199dd expert cache: --expert-cache-per-layer starts on native packs (#369, dag08)
open_sized seeded every layer's cursor at slot 0, so all layers wrote the same slots and the startup check failed;
the profile fill ended at the first full layer. Now each layer starts in its own range, a full layer skips only
its own pairs, and per-layer mode keeps slots of the largest blob (its ranges ignore the rank a sized slot was cut
for). The default (shared) cache is unchanged.
2026-10-01 20:51:16 +02:00
Niko1221 37fe5b5328 serve: the image encoder on its own GPU, "cuda_device" in the vision section (#408, Efs-O)
Opt-in; without it the encoder gets the engine's environment as before.
2026-10-01 20:49:41 +02:00
Niko1221 6aa51c24d8 qsa_prompt_attn: the tensor-core kernels need sm_75 - a V100 (sm_70) keeps the old kernel (#371)
The host check compared the major version only (>= 7) while the device code traps below sm_75, so an sm_70 card
took the m16n8k8 kernel and hit __trap(). sm_75 and newer: the same choice as before.
2026-10-01 20:47:40 +02:00
Niko1221 0c8b8dc4d3 tests: find_nvcc's test ignores a real CUDA toolkit on the host (#414) 2026-10-01 20:46:32 +02:00
Bortlesboat 36beee903b setup: stop on EOF instead of accepting prompt defaults 2026-10-01 20:46:19 +02:00
Niko1221 318d74d9d2 serve: a template refusal answers 400 (#365); the temporary image goes when the encoder's pipe fails (#352) 2026-10-01 20:46:18 +02:00
Niko1221 082d59640f docs (AMD): Windows - the ready-made HIP engine, what it needs, what is validated, how to report 2026-10-01 20:34:37 +02:00
26cc76347a setup: AMD on Windows - the ready-made HIP engine, chosen by itself without a usable NVIDIA card
START-HERE.bat on a Windows PC whose AMD Radeon is the card Strata can use now
sets it up like an NVIDIA one: no compiler, no ROCm install, only the AMD driver.

- Detection without any AMD software: the display adapters present
  (Win32_VideoController: name + PCI id) with each one's 64-bit VRAM size from
  the display-class registry (readable without admin; #247/#325's source),
  the arch by PCI device id, else by name. An integrated or older Radeon is
  listed and refused, as on Linux. The NVIDIA-or-AMD choice is the Linux one:
  AMD by itself when no NVIDIA card can be used, asked when both can.
- The engine: strata-windows-x64-hip.zip from the release (this version's,
  then the latest; --prebuilt for a local folder), unpacked into engine/ and
  checked for this card's arch and version >= 0.1.33; an older one is
  replaced on a later start like the NVIDIA zip. Without a published zip setup
  says how to make one (tools\hip\build_windows.bat + --prebuilt).
- Before the model download, the engine's own strata-device --list-devices
  (with its rocm/bin on PATH) must see the card - otherwise it stops and
  points to the AMD driver - and gives the card's HIP ordinal (#325: an
  integrated Radeon is HIP's device 0). From then on setup lists the AMD
  cards in HIP's order, so "gpu" in the config and --gpu N are HIP numbers.
- The config: backend hip, lib_dirs = engine\rocm\bin, the hipBLASLt table
  for the zip's hipBLASLt version (BUILD.json). Images on the CPU and
  several AMD cards (--gpus) stay Linux-only for now (said, not silent).
- Texts: setup's description and step 1 name NVIDIA or AMD; START-HERE.bat
  needs "an NVIDIA or AMD graphics driver"; the "AMD backend runs on Linux
  only" stop is gone.
- An installed HIP engine's BUILD.json ("gfx1201" archs) is no longer read as
  an NVIDIA one by get_prebuilt.

Linux is unchanged (KFD detection, TheRock wheels, compiled here).
Tests: tools/test_setup_amd.py WindowsDetection (mocked WMI/registry rows,
the list-devices parser on this PC's real output, the iGPU-first ordinal
match, the driver-missing stop, the zip install/refusals, the dispatch);
test_setup_choices' Linux CPU-encoder case pins WIN=False.

Co-authored-by: Dimitri Vasdekis <[email protected]>
Co-authored-by: isshin <[email protected]>
2026-10-01 20:34:03 +02:00
Niko1221 b8349a8144 tools/hip: build_windows.bat and package_windows.py - the ready-made Windows HIP engine zip
build_windows.bat builds strata.exe + strata-device.exe (and with `tests` every
HIP test) for gfx1100;gfx1101;gfx1102;gfx1200;gfx1201;gfx1030 with no admin
rights and no AMD GPU: ROCm 10.2.0a20260930 from AMD's TheRock wheels
(whl-next: rocm[libraries,devel,device-<arch>...], the version #325 ran on an
RX 9070 XT) in a private venv (.rocm-win, ~7 GB), ROCm's clang as host and HIP
compiler with MSVC's headers/linker from vcvars, STRATA_PORTABLE (AVX2 ggml).

package_windows.py makes dist/strata-windows-x64-hip.zip (524 MiB, 810 MiB
unpacked): strata.exe (61 MiB), strata-device.exe, BUILD.json (backend hip,
archs, ROCm and hipBLASLt versions, lib_dirs rocm/bin), rocm/bin with the DLLs
the programs import followed through ROCm (amdhip64_7, hipblas, rocblas,
rocsolver, libhipblaslt, libtensilelite-host, origami, rocm_kpack) + amd_comgr
(loaded by name) + the MSVC runtime, rocBLAS/hipBLASLt kernels for these archs,
rocBLAS's kpack device code (found at ../.kpack from rocblas.dll), licenses and
a NOTICE. The user needs only the AMD driver (the HIP runtime uses its
user-mode components).

Checked on this PC's Ryzen iGPU (gfx1036, a test build of the same tree): with
only the zip's rocm/bin on PATH, strata-device lists the card and
hipblasGemmEx BF16 -> FP32 (T=100) matches the CPU (max err 4.7e-7); ctest
52 of 56 pass, the 4 failures the same as #325's RX 9070 XT run (hip_handoff,
and ple_parity / expert_parity / pool_test without the pack fixture).
2026-10-01 20:33:37 +02:00
36c11637f5 HIP on Windows: the hipBLAS sticky error (Windows only) and the mapped embedding fallback (#247, #325)
- prefill gemm: on Windows hipBLAS can return success with the right BF16/FP16
  product and still leave hipErrorInvalidValue set (#247 on gfx1201: hc up once
  T >= 96, the router), which the next kernel check turns into an exit. That
  one stale error is cleared after a GEMM that succeeded; any other error
  still stops the engine. Only under __HIPCC__ && _WIN32: on Linux a stale
  hipErrorInvalidValue is a real error and keeps being reported (our review
  of #247). CUDA compiles a ((void) 0).
- native embedding (HIP builds): when the runtime refuses a device alias for
  the mapped table, the table goes into VRAM (as when it cannot be pinned)
  instead of failing the start; the pinned copy is freed so the destructor's
  one-of-two rule holds (#325's version leaked it and freed dev_owned_ twice).
  CUDA keeps the old code path.
- tests/hip/mapped_alias (#325): what a hipHostGetDevicePointer alias is on
  this stack. On Windows it is the host pointer itself, and a D2D copy into it
  does not land - hip_handoff's timeout there; kernels reading it work (the
  engine serves on Windows). Rewritten to read the kernel's output back with
  hipMemcpy (the PR's version dereferenced device memory on the host) and to
  try the kernel read in both cases. Diagnostic: exits 0, 77 without a device.

Co-authored-by: Dimitri Vasdekis <[email protected]>
Co-authored-by: isshin <[email protected]>
2026-10-01 20:20:09 +02:00
Niko1221andDimitri Vasdekis 51fdc43c80 strata-device --list-devices, and HIP's numbering for AMD cards on Windows (#325)
On Windows the AMD cards are found in the display-adapter order, but
HIP_VISIBLE_DEVICES counts the devices the HIP runtime enumerates, and an
integrated Radeon takes HIP's device 0 and pushes the discrete card to 1 - so
the engine was pointed at the iGPU, which the arch check then refuses (found
by dvasdekis on an RX 9070 XT + Ryzen iGPU).

- device_count() and device_summary(): enumerate without device_info's arch
  check (the cards this binary has no code for are part of the answer).
- strata-device --list-devices: every device the runtime enumerates, as
  "device N: <name>", then "arch gfx1201, 15.9 GiB, wave32" (CUDA: compute
  capability and VRAM), then "cannot run: <why>" for a card this binary
  cannot run. setup.py parses it (next commit).
- server.py hip_visible(): a one-card config's "hip_ordinal" (written by
  #325's branch) wins over "gpu"; a layer split keeps its list.

Checked on this PC's own Ryzen iGPU through TheRock's Windows HIP runtime:
"device 0: AMD Radeon(TM) Graphics / arch gfx1036, 28.1 GiB, wave32 / cannot
run: ... not an architecture this Strata engine was compiled for (...)".

Co-authored-by: Dimitri Vasdekis <[email protected]>
2026-10-01 20:19:56 +02:00
isshinandDimitri Vasdekis eecb32abf6 HIP on Windows: the build with ROCm's clang as the host compiler (#247, #325)
CMake refuses to mix cl.exe with Clang for HIP, so on Windows ROCm's clang++
compiles the host code too (targeting the MSVC ABI, with MSVC's headers and
linker from vcvars). The CUDA->HIP shim is force-included with /FI when the
host front end is MSVC-style (cl / clang-cl) and with -include otherwise, its
path in forward slashes; CMAKE_HIP_ARCHITECTURES may also be space-separated.
On Windows the shim keeps <windows.h>'s min/max macros out (NOMINMAX,
WIN32_LEAN_AND_MEAN).

Taken from jagsan-cyber's #247 as ported by dvasdekis in #325 (2373c75); the
arch lists and the arch check stay 0.1.32's. Built on this PC with ROCm
10.2.0a20260930 (TheRock wheels, the version #325 ran on an RX 9070 XT) for
gfx1100;gfx1101;gfx1102;gfx1200;gfx1201;gfx1030: strata.exe and all 253
targets with the tests. CUDA builds do not read these files.

Co-authored-by: Dimitri Vasdekis <[email protected]>
2026-10-01 20:19:37 +02:00
Niko1221 885e52052b tools/strata_mcp.py: an MCP server so an AI assistant can install, start, check and stop Strata (#133)
Claude Code, Claude Desktop, Cursor, VS Code, Codex ... add it over stdio (JSON-RPC 2.0, MCP 2025-06-18, also
2025-03-26 / 2024-11-05); standard library only, so it runs before setup made .venv. Eight tools: strata_status
(running server via /health, /v1/status, /status, /metrics; installed configs, engine, data folder; hardware and
setup's recommendation), strata_models, strata_install (setup --yes --no-start in the background, plan first and
confirm=true to start, progress / cancel on later calls), strata_start (the run script's command, waits for
/health), strata_stop (POST /unload, then ends the server it started), strata_logs, strata_benchmark,
strata_connect_info.

Every argument is checked against a schema and setup's own tables; the only path (data_dir) must be the data
folder, inside the Strata folder, or a new Strata* folder outside system folders. Processes are tracked by pid and
start time in .strata-mcp/, so only processes it started are ever ended; a Strata started elsewhere is only
unloaded. docs/MCP_SERVER.md has every client's config; docs/DETAILS.md links it. Tests use a fake setup and
server (Windows and Linux).
2026-10-01 20:18:07 +02:00
Niko1221 f938412499 docs: a short README for regular people; the details move to INSTALL, MODELS, TROUBLESHOOTING, HOW_IT_WORKS
README.md (279 -> ~180 lines): what it is, speed on an NVIDIA and an AMD baseline PC side by side (the AMD
numbers are TODO-AMD placeholders until the RX 9070 XT baseline is measured), what you need with NVIDIA and AMD
as equals, install by asking an AI assistant (docs/AI_SETUP.md, MCP server) or by hand, pick-by-RAM table,
using it, the four common problems, how it works with the two diagrams, Buy Me a Coffee button below the speed
table and at the end. Its anchors other pages use (#which-model-should-i-pick, #install, #using-it,
#something-went-wrong, #how-does-it-work) are kept.

Everything the README had moves, nothing dropped: docs/INSTALL.md (requirements, Windows, Linux, AMD cards,
several cards, Docker, updating, where things are stored, setup's questions incl. rope scaling / images /
speed projection, calibrate, all options), docs/MODELS.md (speed per size, sizes, will it fit, low-RAM, Coder,
Swift 1.5, Unsloth UD-Q4_K_XL, Orca), docs/TROUBLESHOOTING.md (the README's problems plus AMD ones),
docs/HOW_IT_WORKS.md (the longer explanation, credits, license). New docs/AI_SETUP.md (instructions for an AI
agent installing Strata for a user) and AGENTS.md. .github/FUNDING.yml (buy_me_a_coffee: strataengine).
AMD_HIP.md loses "Experimental" in its title and intro; DETAILS.md lists the AMD cards and points to the new pages.
2026-10-01 20:08:58 +02:00
Niko1221 0ad23b584d setup: the AMD backend is no longer called experimental in its messages
--backend's help, the NVIDIA-or-AMD question and the GPU line drop "experimental" (the Unsloth model, the
speed projection and the Pascal/Volta build keep it); the AMD choice says images run on the CPU (0.1.32)
instead of "no images". setup.sh's header comment likewise.
2026-10-01 20:08:58 +02:00
Niko1221 2ca1b6645f docs (AMD): images through the CPU encoder and the Monitor's AMD readings (0.1.32, #304 #301) 2026-10-01 18:45:09 +02:00
Niko1221 3f11e286b4 docs: #337's top-k dispatch is AMD-only 2026-10-01 18:45:00 +02:00
Niko1221 01ec3ac900 qsa_select (#337 follow-up): the top-k dispatch by active blocks on HIP only - CUDA keeps the capacity rule (the 64K prompts read 1-3% slower with it on an RTX 5070; #337 was measured on RDNA4) 2026-10-01 18:44:53 +02:00
Niko1221 5a826638b4 qsa_select (#337 follow-up): the gfx12 WMMA block scorer is opt-in (STRATA_SELECT_WMMA=1) - it selects slightly differently from the warp kernel; the top-k dispatch by active blocks (the same ids) stays on; docs 2026-10-01 17:58:46 +02:00
bsorensen110 dd670cac36 hip: gfx12 (RDNA4) QSA select - WMMA block scorer, and a top-k dispatch that follows the prompt, not the capacity
Two changes to the prompt path's QSA selection (qsa_select.cu), measured on a Radeon AI PRO R9700 (gfx1201) with
--max-context 262144:

1. qsa_block_topk picked its register kernel by max_blocks, a CAPACITY (--max-context / 4 + 2). With a 262144-cell
   capacity every prompt, even a 4K one, took the slow reference top-k (up to ~10x slower at 128K+). The prompt path
   now passes the batch's active block count (the argument qsa_block_scores already takes); decode passes none and
   keeps the capacity rule and the original register width, so it is unchanged. On HIP the register kernel is
   instantiated for 66 blocks per thread (contexts up to 270,336 cells, the 262144 --max-context) and, below
   ~28K cells, where it measured 0.2-0.9x of the reference on gfx1201, the reference is kept. Same ids as the reference
   (checked 256/256 at every size).

2. qsa_block_scores_tc on gfx1200/gfx1201: block_scores_wmma_kernel on v_wmma_f32_16x16x16_bf16 (wave32). Each operand
   is split into three bf16 slices (hi / mid / lo are exact 8-bit mantissa slices of the fp32, so no scaling is
   needed) and the six products of order <= 2 are summed; the hi*hi chain keeps its own accumulator and the five
   corrections another, added once at the end (a single shared accumulator rounded the corrections at the big
   accumulator's ulp: 8e-7 of the score scale, failing the harness's gate at short context; split: 1.6e-7, the warp
   kernel's own error is of the same order). The tail block n_bid is the warp kernel's arithmetic. Like the TF32
   kernel it is not bitwise with the warp kernel (a near-tie can select differently). Gated at compile time
   (__gfx1200__/__gfx1201__) and run time (gcnArchName); other HIP targets keep the warp kernel; CUDA is unchanged.

qsa_select_bench now builds on HIP, takes the engine's capacity as a 4th argument, checks the scorer against an FP64
reference (gate: no worse than 4x the warp kernel's error, floored at 1e-6 of the score scale), exits non-zero on a
failed gate, and is registered with ctest (32K context under a 262144-cell capacity).

R9700, synthetic bench, 256 queries, capacity 262144: scores 0.81 -> 0.18 ms at 32K, 3.5 -> 0.65 ms at 131K, 6.8 ->
1.5 ms at 262K; top-k 3.9 -> 0.51 ms at 131K, 12.9 -> 1.3 ms at 262K. End to end (Swift 1.5 IQ3_XXS, --kv int8,
prefill timing, same binary with STRATA_SELECT_OLD=1 STRATA_TOPK_OLD=1 as the old arm): 107.7K-token prompt 79.5 s ->
59.5 s (1.34x; qsa select 23.3 s -> 3.4 s); 30.9K 1.02x; below 10K about 1.01x. ctest 44/44.
2026-10-01 17:58:17 +02:00
Rafael Grossi 7d15ef727b AMD HIP: RDNA2 gfx1030 (tested), gfx(1031, 1032) untested.
dp4a through the plain signed v_dot4_i32_i8 (RDNA2 has no sudot4).
setup knows the card and the gfx103X-all TheRock index.
docs carry a community report (RX 6900 XT 16 GB: complete build, 28/28
ctest, 38-42 tok/s decode at 131,072 context, 330-339 tok/s prefill)
2026-10-01 17:58:17 +02:00
bsorensen110 a8d57b1f00 hip: address review of the gfx1201 table: current docs, smoke-test wording
docs/AMD_HIP.md: the RDNA4 validation note still said there was no gfx1201 table and none was shipped; it now says
that holds for hipBLASLt 1.4.1 and points to the 1.5.0 table and its measured gain. The "Tuning table" text no longer
says the parity test "exits non-zero when the table does not fit". It describes what the test checks (architecture and
version through the loader, two of the 32 rows against hipBLASEx) and what it does not: a solution id the library
rejects falls back to hipBLASEx and the test still passes, so it says to read STRATA_HIPBLASLT_VERBOSE's
"fallbacks=" summary and to recalibrate for another 1.5.0 build.

tests/hip/prefill_hipblaslt_gemm.cpp: the header comment, the SKIP and missing-row messages and the case comment now
say it is a smoke test over two rows, and it prints "smoke test: 4 cases on 2 of the table's N rows" at the end.
Test behaviour and exit codes are unchanged.
2026-10-01 17:57:41 +02:00
Soren-ldandCopilot Autofix powered by AI cfe6021eb2 Update AMD HIP documentation for hipBLASLt 1.5.0
Clarify usage of hip_prefill_hipblaslt_gemm and tuning process for different 1.5.0 builds.

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-10-01 17:57:24 +02:00
bsorensen110 025602c2be hip: encode the BF16 fixtures of the hipBLASLt tuner and parity test with bf16_from_f32()
Both helpers built their BF16 inputs with hip_bfloat16(x).data. Compiled as host C++ (no __HIPCC__) the SDK's
hip_bfloat16 is a struct holding a uint16_t, and the conversion from a float is not the engine's round-to-nearest-even
BF16 encoding (kernels/bf16_bits.hpp has that one).

Observed effect on gfx1201 with ROCm 10.2.0a, parity test with the 100500 table: the T=37 beta=1 BF16 case printed
relative_l2=0 max_abs=0 with the old encoding and relative_l2=3.6e-07 max_abs=2.6e-06 with this one; the T=4096 BF16
case prints 0 either way, and the f16 cases are unchanged. I did not inspect the old encoder's bit patterns directly,
so the exact fault is inferred from that difference, not shown.

Use strata::kernels::bf16_from_f32() in both, so the BF16 rows of gfx1201-hipblaslt-100500.txt were calibrated on
intended inputs and anyone regenerating a table gets the same. No change to the engine or to the f16 path.
2026-10-01 17:57:24 +02:00
bsorensen110 dd088d3324 hip: hipBLASLt tuning table for gfx1201 (Radeon AI PRO R9700) with ROCm 10.2.0a nightly (hipBLASLt 1.5.0)
tools/hip/gfx1201-hipblaslt-100500.txt: 16 dense GEMM geometries (bf16 and f16) at T=4096 and T=8192, 32 rows,
calibrated on an R9700 with tune_hipblaslt (16 candidates per row, relative L2 <= 1e-4, max abs <= 1e-2, no padding
writes) against rocm-nightly-gfx120x-all 10.2.0a20260914, hipBLASLt 1.5.0 (version number 100500).

The engine refuses a table of another hipBLASLt version, so the existing gfx1201 table in #262 (100401, hipBLASLt
1.4.1) is not used on this ROCm; setup picks tools/hip/<arch>-hipblaslt-<version>.txt by the installed version, so both
can be shipped side by side. Compared with #262's 24 rows this table has the same 24 shapes (with solution ids chosen
for hipBLASLt 1.5.0: 13 of the 24 ids are the same) and 8 more, all bf16 (N=1/K=2560 at T=8192, N=4/K=10240, N=512/K=2560
at T=4096, N=2560/K=2560 at T=4096, and N=10240 with K=2560 and K=320 at T=4096/8192).

docs/AMD_HIP.md lists the shipped tables and how to check one.
2026-10-01 17:57:24 +02:00
Niko1221 99b3bde00a Engine 0.1.32: split prompts faster with the same output (#340), AMD router +12% decode (bit-identical), RDNA4 matrix-core prompt attention (opt-in), Unsloth UD-Q4_K_XL in setup (experimental) with 2.8x faster prompts, fixes #326 #327 #342 #344 #331, the PR batch; setup requires it 2026-10-01 15:25:30 +02:00
Niko1221 97712287fe serve (merge #333 + #344): the engine stays not-alive from the start of __init__ until READY - #333's lazy start set ended=False before READY, so a request could again plan with context 0 (RestartWindow test) 2026-10-01 15:17:14 +02:00
Niko1221 b0abdeb446 Merge branch 'f-132-pr' into rel-0.1.32
# Conflicts:
#	CMakeLists.txt
#	serve/server.py
#	src/program/generate.cpp
2026-10-01 15:10:33 +02:00
Niko1221 aebfd69a2f Merge branch 'f-132-amd' into rel-0.1.32 2026-10-01 15:09:45 +02:00
Niko1221 d9fe3019e0 Merge branch 'f-132-q4' into rel-0.1.32
# Conflicts:
#	setup.py
2026-10-01 15:09:45 +02:00
Niko1221 3b7162131d #278 follow-up: an Anthropic request that doesn't ask for thinking renders without it only with "anthropic_thinking": "on_request" in the config - the default thinks as 0.1.31 did (a client that never asks would otherwise lose the thinking on every turn); docs 2026-10-01 14:42:53 +02:00
Niko1221 e12ecf6ac8 #280 follow-up: the rope kernels without the table are 0.1.31's code exactly (template <bool TAB>)
With STRATA_ROPE_TABLE unset the table was skipped at run time, yet the compiled default kernels changed their
results: the model-level parity run (Swift IQ2_XS, tools/conversation_cache_parity.py, STRATA_STATE_HASH) gave the
same tokens but other GDN / indexer / K/V state hashes than 0.1.31's (bisected to #280). native_rope's apply,
the indexer's append / append_first / append_blocks and the prompt path's rope_kernel are templates on TAB now;
the host launches <false> whenever no table applies, and that instantiation has no table read at all. Checked:
the same tokens and every state hash as round 1 with the whole branch.
2026-10-01 14:34:20 +02:00
Niko1221 dadc9e781a #340: a split's own prompt buffers are opt-in (STRATA_SPLIT_OWN=1, or auto for the 12% rule) - the owner's choice: the default borrows as 0.1.31 did, with the concurrent refill, the 96-blob split ring and own buffers only on a card with free VRAM, so the split's output stays 0.1.31's (2K +27%, 16K +5%, decode unchanged on the R9700 + 9070 XT); docs 2026-10-01 14:30:39 +02:00
Niko1221 e6a06be558 HIP (S6): STRATA_HIP_WMMA=1 also takes a 96-slot prompt expert ring (9070 XT 4K prompts 718 -> 1,211 tok/s with it); docs/AMD_HIP.md: the router and WMMA switches, with credit to PR #329's equal-speed kernel 2026-10-01 13:06:56 +02:00
Niko1221 c92c4540e3 #340: with the split's own prompt buffers (its rule, not --no-prefill-borrow) the boundary is searched as the borrowing configuration would; the reserve only shrinks the caches (STRATA_SPLIT_OWN_PLACE=reserve: the search sees it)
9070 XT + R9700: the reserve moved K 28 -> 32 and split decode 58.5 -> 54.8 tok/s; own buffers at K=28 decode 58.4.
2026-10-01 11:34:19 +02:00
Niko1221 bf7b181a2c prefill: the Q4_K / Q5_K / Q5_1 MMQ kernels are a build option (STRATA_MMQ_KQUANTS, off) - with every kernel loaded at start they took 1-3 expert slots from every model
The engine sets CUDA_MODULE_LOADING=EAGER (a kernel loaded mid-prompt found no VRAM), so the three extra mmq
instances (+24 MB of binary) were resident for every model: IQ3_S 2261 -> 2260 slots, Q2_0 3678 -> 3675 (S1's A/B,
IQ3_S story decode ~1.5% lower). Measured with the IQ3_S setup config against 0.1.31 (wt-131-r9 build-release):
- slots 2261 / free 5.25 GiB, the same as 0.1.31 again (2260 / 5.24 with the instances);
- engine threads the same (23 at READY, 25 after a request, 0.1.31 likewise): nothing new runs for a normal pack -
  the stager's 32 threads are only for a source with file-read blobs, the verify hook only with STRATA_LOGPOS;
- 5 alternating pairs, greedy story / code / third prompt: 0.1.31 39.5 / 44.7 / 32.0, this 40.3 / 45.2 / 33.3
  tok/s (story B >= A in 5 of 5).
The UD-Q4_K_XL prompt is SSD-bound with or without MMQ (29 s per 4K chunk either way), so the released engine keeps
FP16 products for it; prefill_mmq_kquant_test is built with the option.
2026-10-01 11:31:20 +02:00
Niko1221 560e4788a1 #290 follow-up: --bf16-embd's output buffer holds the 97 gathered rows, not only the 61-row table
The gather wrote 97 rows into a 61-row device buffer (and read them back into a 61-row vector); the next launch
reported the overrun as "iq_dequant_f32: invalid argument". The buffer is max(gathered, table) rows now.
2026-10-01 11:06:21 +02:00
Niko1221 0239cffde7 #296 follow-up: only the Q5_0 MMQ instance and case stay (Q4_K / Q5_1 come with S5's STRATA_MMQ_KQUANTS)
S5's branch (f-132-q4) compiles the Q4_K, Q5_K and Q5_1 MMQ instances on CUDA and enables them with the private
define STRATA_MMQ_KQUANTS; with both, moe_mmq.cu would have duplicate case labels. STRATA_ORCA_Q4KS_MMQ now adds only
q5_0 (OrcaRouter's Q5_0 expert downs; HIP included) - supported() and run() take Q5_0 under it.
2026-10-01 10:31:23 +02:00
sergiywith d11ea857df PLE: the n-gram table in FP8 E4M3, as Qwen ships it
The engine read the 51.2e9-value n-gram table only as IQ4_NL (ISTA-DASLab's shard 2, 90 B/row), which is 8.1% off the
checkpoint's own values per row (correlation 0.996-0.997 on rows from all 128 shards: same table, same order). The
checkpoint stores it as 128 shards of [2500012, 160] F8_E4M3 plus one weight_scale.

- tools/ple_fp8_pack.py copies those bytes, unchanged, into a one-tensor GGUF: per_layer_token_embd.weight
  [160, 320001536] as I8, with strata.ple.format = f8_e4m3 and strata.ple.scale. 51.2 GB, streamed.
- PleTable opens either format; the row size (90 / 160) and the decode (IQ4_NL codebook / an E4M3 table times the
  scale) follow the file. PleReader takes the row size at open; its cache sizes itself by it. GGUF reader: type I8.
- A PLE-only table (architecture strata-ple) is not added to the dense-projection sources.
- The start line names the table's format.
- ple_fp8_parity: rows through Direct, Mmap and gather_batch match torch's decode of the checkpoint bit for bit.

First-token KL of the IQ4_NL table against the FP8 one: 0.0009 (1K), 0.0013 (2K), 0.0002 (4K), 0.022 (32K); mean 0.0061, top-1 4/4.

Rebased onto 0.1.31 + #317 + #296 (Niko1221): one row size (`rb`) and one decoder in PleTable for the three table
formats - IQ4_NL, #296's Q5_0 (110 B rows, the mapped reader only, as #296 has it) and FP8 (160 B) - and #317's
SSD keep-alive picks its page inside the table at the table's own row size; 0.1.31's shard list for the dense
sources keeps the PLE-only exception; ple_fp8_parity builds with STRATA_BUILD_TESTS beside #296's checks. The
"PLE on" start line stays 0.1.31's.
2026-10-01 10:31:23 +02:00
Niko1221 2ba4504b71 prefill: UD-Q4_K_XL prompts 2.8x faster (16K: 57 -> 160 tok/s on an RTX 5070) - 32 stager threads and a 128-deep ring when the experts are read from the GGUF in place; MMQ for its Q4_K / Q5_K / Q5_1 experts
- The prompt path streams every expert once per 4K chunk; with the GGUF read in place ~35 GB of that comes from
  the SSD as page faults, and 4 copy threads with 16 blobs in flight read it at ~0.6 GB/s. When the expert source
  has transient (file-read) blobs the stager now defaults to 32 threads and a ring of 4 x threads (the env
  overrides stay); every other source keeps 4 / 16. Measured (4K chunk): 71 s -> 34 s (16/64) -> 29 s (32/128);
  16K prompt 273-282 s -> 99 s.
- moe_mmq: Q4_K, Q5_K and Q5_1 in supported() and the dispatcher, their mmq instances compiled for CUDA
  (STRATA_MMQ_KQUANTS; HIP unchanged). The dispatch follows eddoursul/Strata 7706c1b; main's prompt path already
  picks MMQ per layer. GPU time of the experts' products 4x lower (10 s -> 2.4 s per 4K chunk), not visible in
  the wall time on this PC (SSD-bound either way: 29 s with and without); after a 16K prompt the continuation
  agrees with llama.cpp at 92.2% (KL 0.029) against 90.2-90.6% (0.038) with the FP16 products. The other
  models' formats are not affected (their layers' types decide; none uses these three).
- tests/cuda/prefill_mmq_kquant_test: the four expert formats through MMQ against ggml's dequantized weights
  (synthetic, several experts per group, permuted rows, a zero row; rel. L2 <= 0.34%), and Q6_K stays uncovered.
2026-10-01 10:30:41 +02:00
Niko1221 d70df548ad #340: a layer split keeps 0.1.29's own prompt buffers (2048-token chunks, priced into the split search) when they are at most 12% of every card's VRAM; smaller cards keep borrowing (STRATA_SPLIT_OWN=1/0 forces)
Measured on 9070 XT + R9700 (Coder IQ1_M): 2K/4K/8K/16K prompts 1588/1771/1943/2006 tok/s (0.1.29 1568/1730/1870/1935,
0.1.31 993/1212/1350/1852). One GPU unchanged; --split-skip-if-fits keeps the loans.
2026-10-01 10:25:03 +02:00
Niko1221 b7bb142060 #340 prototype: STRATA_SPLIT_SMALL_MAX=M - a split request of at most M prompt tokens reads in the stages' own S-token chunks (nothing lent) 2026-10-01 10:04:08 +02:00
Niko1221 242248ceed setup: Unsloth UD-Q4_K_XL as an experimental choice (--family unsloth) - the four shards pinned at 38bb39e with their sizes and SHA-256, a RAM budget of the PC's RAM less 24 GB, packed with --compat-bf16, engine 0.1.32 or newer
- FAMILIES["unsloth"] (marked [experimental], not the default) and MODELS["UD-Q4_K_XL"] ("budget": True,
  48 GB of RAM or more); HF_REVISIONS pins unsloth/Qwen3.8-Flash-Next-GGUF; families may have more than two
  shards ("shards": 4).
- The download resumes as before; then verify_sha256 checks each shard's size and SHA-256 once (remembered in its
  finish mark; a wrong file is deleted so the next run downloads it again).
- resident_budget_gib: round(RAM) - 24 GiB (40 on 64 GB, the measured setting), at most the 71 GiB of experts, at
  least 8; a KV cache streamed to RAM (64K+) comes out of it. --resident-budget-gib replaces the low-RAM mode for
  this model (no experts.bin; --low-ram is ignored with a warning); one GPU, no images, no speed projection; 8K
  context recommended on GPUs under 14 GB; no --ple-gguf for a 4-shard file (the engine finds the PLE shard).
- The engine version is checked right after the engine step, before the 111 GB download.
- choices_from_config reads a size with a dash (strata-unsloth-ud-q4_k_xl.json).
- tools/test_setup_unsloth.py: pins, budget, SHA-256 handling, config round trip, and setup.main() end to end with
  every outside effect mocked (URLs, pack flags, engine args, old engine, too little RAM, two GPUs + images).
- README and docs/UNSLOTH_Q4.md / DETAILS.md describe it.

The model support itself follows eddoursul/Strata (PR #245).
2026-10-01 09:33:20 +02:00
Niko1221 796655c6ff docs: UD-Q4_K_XL against llama.cpp on the same GGUF - 99.0% argmax agreement on a code answer, 97.5% on a thinking answer, 90-91% after a 16K prompt, every difference where llama.cpp's own top two are close
Same token sequences through Strata (40 GiB budget, greedy, adaptation off, STRATA_LOGPOS_TOPK=20 on the verify
windows) and llama.cpp 3cf0325 (CPU, mmap, top 20 from llama_get_logits_ith): argmax agreement, top-5/10 overlap,
KL over the top 20, perplexity. Code 99.0% / thinking 97.5% (the fork, eddoursul/Strata: 98.2-99.3% / 95.6-95.8%);
after a 16K prompt 90.2% on Strata's greedy continuation and 91.2% on real text, the same real-text positions with
512 tokens of context 89.2% - so not a long-context loss: all positions with a top-1/top-2 gap of 0.5 or more agree
(and every code/thinking position from 0.2), perplexities within 3%.
2026-10-01 09:32:19 +02:00
Niko1221 88f4146c53 #340 prototype: a split's streamed ring is 96 slots when most experts are resident (STRATA_SPLIT_RING); STRATA_SPLIT_SMALL_OWN=S keeps an S-token chunk's buffers per lending stage for the session; --no-prefill-borrow on a split books the ring (it no longer starves the draft head)
One GPU: unchanged (all three rules are for layer splits only).
2026-10-01 09:22:58 +02:00
Niko1221 5fd4f2670f #296 follow-up: ple_q5_parity / q5_projection_parity build only with STRATA_BUILD_TESTS; the doc says what runs where
The two real-weight checks were added to every CUDA build with native experts (a full release build compiled them);
they are test programs. docs/ORCA_Q4_K_S.md: the build flag for them, and that 0.1.31's GPU expert kernels take
Q4_K gate/up with Q5_1 or Q8_0 downs (Q5_0 is not among them yet: the engine refuses such a pack at start, naming the layer).
2026-10-01 08:49:35 +02:00
Suoriks 75a39eef3a feat: support OrcaRouter Q4_K_S
Load three-shard Uncensored weights while preserving Q5_0 PLE rows
and Q4_K/Q5 expert bytes.

Add native CUDA and MMQ paths with real-weight parity checks.

Rebased onto 0.1.31 (Niko1221): 0.1.31 already loads each expert role from its own shard (the per-role shard column
of native_experts.txt, iq_pack.py, expert_source.cpp, native_expert_parity's multi-shard mode) and has its own
Q4_K/Q5_K/Q5_1/Q8_0 GPU expert decoders, so this PR's gguf_role_file / v4 manifest, its iq_pack.py and
test_iq_pack.py changes and its iq_kernels.cu decoders are dropped. Kept: Q5_1 in native_mmvq (type 7 through
0.1.31's iq_mmvq) and in the BF16 dequantizer, the Q5_0 PLE table (mapped reader), the Q4_K/Q5_0/Q5_1 MMQ
instances under the STRATA_ORCA_Q4KS_MMQ option (OFF), ple_q5_parity / q5_projection_parity and the doc.
2026-10-01 08:49:03 +02:00
Niko1221 cf3b351c19 #272 follow-up: --pool-affinity all (the pool's layout so far) is the default; auto and p-cores are opt-in
#272 made Auto the default, which on a hybrid CPU moves the workers (P-core primaries, then SMT siblings, then
E-cores) and cuts their number to P-cores - 1: a speed change nobody has measured here, on the machines most
users have. The default is All - the same cores in the same order as before on every CPU (Windows: the first
logical processor of each core; Linux: one allowed CPU per (package, core)), and the same worker count - in
Options, ExpertPool, physical_cores() and detect_cpu_topology(). --pool-affinity auto | p-cores opts in; the
hybrid line is printed only then. Usage text says so.
2026-10-01 08:47:56 +02:00
Pravesh Khatana 69743d3a5c feat(cpu): add hybrid architecture awareness and --pool-affinity for P/E-core CPUs
- Add detect_cpu_topology() for Windows (GetLogicalProcessorInformationEx) and Linux (/sys/devices/system/cpu/cpu*/cpu_capacity)
- Identify hybrid P/E-core topology and SMT sibling structures
- Prioritize primary P-cores for worker allocation, reserving core 0 for host thread
- Default pool_workers to (p_cores - 1) on hybrid CPUs when unspecified
- Add --pool-affinity auto|p-cores|all CLI option
- Maintain 100% backward compatibility on non-hybrid CPUs and default configs
2026-10-01 08:47:28 +02:00
Niko1221 029e10d9d9 #290 follow-up: a BF16-row parity test (native_expert_parity --bf16-embd)
iq_embed_rows (97 rows gathered out of order, with repeats) and iq_dequant_f32 on a random BF16 table, every value
compared bit for bit with the exact widening (bits << 16); embed_type_supported(30) / iq_supported(30) /
iq_row_bytes(30). ctest native_expert_parity_bf16_embd.
2026-10-01 08:46:48 +02:00
sergiywith 32727a7a94 Token embedding in BF16 from the checkpoint (--embd-gguf)
- tools/embd_bf16_pack.py copies embed_tokens (BF16, as the checkpoint ships it) into a one-tensor GGUF; only the
  safetensors shard that holds it is needed (3 GB for Qwen/Qwen3.8-Flash-Next).
- --embd-gguf PATH: NativeEmbed reads token_embd from that file; iq_embed_rows and iq_dequant_f32 decode BF16
  (embed_type_supported). Mapped host memory as before (1.2 GiB instead of 0.3 for IQ4_XS), no VRAM.

First-token KL of the GGUF's IQ4_XS embedding against the BF16 one: 0.0015 (1K), 0.0089 (2K; the top token changes), 0.0010 (4K), 0.037 (32K); mean 0.012, ~100x the run-to-run noise. On the NVFP4 fork, whose GGUF embedding is Q8_0, the same comparison gave 0.0025. Decode and prompt speed are unchanged (fork); the start takes ~0.2 s more.

Rebased onto 0.1.31 (Niko1221): NativeEmbed::load takes the shard list (one file for --embd-gguf) and checks the
model's architecture since 0.1.31 - the one-tensor file says "strata-embd", so only its tensor is checked there;
the Q4_K/Q5_K/Q5_1/Q8_0 decoders 0.1.31 added stay beside the BF16 one.
2026-10-01 08:46:48 +02:00
Niko1221 c81244d4e9 #293 follow-up: the drafter stores rotated INT8 K/V as INT8; the rotation is part of the snapshot format
- mtp.cpp record_forward: the `if (st_.kv_rot)` branch rotated K and V and then always appended with
  kv_append_q4_step - for INT8 + STRATA_KV_ROT=1 into Q4_0 pools the state does not have. The rotation and the
  append are separate now: rotate when kv_rot, then append in the state's own format (Q4_0, INT8, FP16).
- conversation_snapshot.cpp: rotated INT8 K/V is its own snapshot format (+16), so a conversation snapshot or prompt
  checkpoint taken with the rotation is refused by a state without it and the reverse ("incompatible K/V
  geometry"); without the rotation the format is unchanged. conversation_snapshot_test checks both directions.
- generate: says when STRATA_KV_ROT=1 applies; the q4_0 line's comment back.
2026-10-01 08:45:09 +02:00
sergiywith f133c655a8 KV: optional Hadamard rotation for int8 K/V (STRATA_KV_ROT=1); the drafter and the batched verify rotation-consistent
INT8 K and V can go through the Walsh-Hadamard rotation --kv q4_0 already uses (kv_q4.hpp): it spreads outlier
channels over the 64-value scale groups before quantization; the queries are rotated too (<Hq, Hk> = <q, k>) and the
output rotated back.

- QsaState::kv_rot: Q4_0 always, INT8 with STRATA_KV_ROT=1 (off by default: the same tokens as main).
- Every writer and reader follows it: decode, the verify window, the prompt path, the MTP drafter, and the drafter's
  batched prompt pass (Prefill::draft_kv) - which rotated only for Q4_0.
- verify.cpp: the batched window path (qb) never rotated its queries; it did not matter while Q4_0 was kept off it.

First-token KL to fp16 K/V, 4 prompts incl. 32K: NVFP4 pack 0.0066 -> 0.0051; IQ2_XS 0.0022 -> 0.0054. Hence opt-in.
2026-10-01 08:44:04 +02:00
Niko1221 0f461187d2 #280 follow-up: the float64 RoPE table is opt-in (STRATA_ROPE_TABLE=1); a freed session's table is forgotten
- The table's angles differ from the fast-math cosf/sinf ones in the last bits, so every output moves: off by
  default (the kernels compute the angle exactly as 0.1.31), STRATA_ROPE_TABLE=1 turns it on (replaces the PR's
  STRATA_ROPE_LEGACY A/B switch).
- The per-device registration was a raw pointer into the session's arena that outlived it: QsaState::owns_rope marks
  the state that built the table (not one that borrowed it with share_rope, like the MTP drafter's), and
  session_release() - to be called before a session's memory is freed - clears every device's entry pointing at
  it (rope_table_release). The latest session of a device now wins, instead of the longest table.
2026-10-01 08:43:38 +02:00
sergiywith 16a369b574 RoPE: every kernel reads the session's float64 angle table
The native decode/verify rope and the indexer's pooled keys computed pos * powf(...) with cosf/sinf under
--use_fast_math (0.0014 rad off at 32K, ~0.02 at 262K), the prompt path with precise float libm, and the float64
table the session builds was read only by the non-native path. rope_table_set registers that table per device
(mrope.hpp); rope_cs reads it, or computes in float64 past its end.

Measured on main with IQ2_XS: first-token KL of the old angles against the table 0.0045 on a 32K prompt (two runs of
main: 0 to 0.00013 on short prompts). On an NVFP4 pack: 0.0063 at 32K and at 125K. Speed unchanged.
STRATA_ROPE_LEGACY=1 brings the old float angles back for A/B.

Rebased onto 0.1.31 (Niko1221): 0.1.31's rope scaling (#84: linear / YaRN, rope_scaled_angle) is in every rope
kernel and in the session's table (build_rope_table with the RopeScaling), so a kernel reads the table only when it
was built with the scaling it was given, and otherwise - or past the table's end - computes the angle exactly as
0.1.31 does (no separate float64 device path); rope_table_set registers the scaling with the table.
2026-10-01 08:43:07 +02:00
Niko1221 5f02176997 verify: STRATA_LOGPOS_TOPK=K adds the K most likely tokens and their log-probabilities to the STRATA_LOGPOS rows
For a per-position comparison with another engine on the same tokens (argmax agreement, top-k overlap, KL on the
top K): docs/UNSLOTH_Q4.md compares UD-Q4_K_XL with llama.cpp this way.  Off unless the variable is set; the
eight existing columns are unchanged.
2026-10-01 08:42:54 +02:00
Niko1221 fc2eb78e0a conversation cache: a turn's parked copy supersedes the previous turn's, so a subagent no longer evicts its parent (#342)
Every request that does not continue the live session parks it, and put() never removed older copies of the
same conversation while make_room evicted oldest-first: a subagent's 9 successive turns (each re-rendering the
previous reply) parked 9 copies and pushed the parent conversation out of 4 slots. drop_superseded() now runs
before make_room (park_current) and in put(): an entry whose DEEPEST checkpoint the outgoing chain (or live
state) still holds - the same conversation a turn back, differing only in the stale reply tail - is dropped
(counted apart from evictions, logged). An entry without checkpoints, of the other steering mode, or whose
deepest checkpoint the outgoing chain lacks (another conversation sharing only the system-prompt root) is kept;
an oversized put drops nothing. The cache is opt-in (#189): with it off nothing runs. Tests in
conversation_cache_test (MAIN -> SUB x9 -> MAIN with 4 slots; the kept cases).
2026-10-01 08:42:33 +02:00
Niko1221 c03efab319 serve: AMD readings in the Monitor tab from Linux sysfs; free VRAM on HIP (#301)
With the AMD backend (config backend "hip") telemetry reads the amdgpu driver's sysfs files instead of NVML:
gpu_busy_percent, mem_info_vram_used/_total, and hwmon temp1_input, power1_average (or power1_input) and
power1_cap. The card is found as setup's amd_gpus() numbers it (KFD topology GPU nodes in order, CPU nodes
skipped, drm_render_minor -> /sys/class/drm/renderD<N>/device), which is HIP_VISIBLE_DEVICES' numbering; a
layer split sums/averages as on NVIDIA. Service.free_vram_mib (min_free_vram_mib) uses it on HIP. NVIDIA
paths unchanged. Test: AmdTelemetry (a fake sysfs tree, two cards and a CPU node). Not yet checked on a real
AMD box.
2026-10-01 08:41:14 +02:00
Niko1221 7d746a0887 #283 follow-up: STRATA_PREFILL_BF16X2 defaults to 0 (off) - the prompt path's numbers stay 0.1.31's
The second (remainder) GEMM changes what the prompt path computes, so it is opt-in: 2 = all but the
hyper-connection's projections, 1 = all. With 0 every lo pointer is null: no extra buffers in carve()/bytes_needed(),
the PLE batch size as before, and every kernel writes exactly the BF16 images it wrote before.
2026-10-01 08:40:29 +02:00
sergiywith a86bc853f3 Prompt path: BF16 projections get the activation's BF16 remainder too
The prompt path fed BF16-rounded activations (2^-9 relative) to the BF16-weight projections, where decode feeds FP32:
the router (a discrete top-10), the indexer (a discrete block selection), SSM alpha/beta, the shared-expert gate, the
PLE key/value and the hyper-connection. Each now takes x as hi + lo, both BF16 (lo = bf16(x - hi)), in two GEMMs
summed in FP32: ~16 mantissa bits, decode's FP32 x to within ~1e-5.

STRATA_PREFILL_BF16X2: 2 (default) all but the hyper-connection, 1 all, 0 off.

IQ2_XS, first-token KL to mode 1: short prompts main 0.0028/0.0069/0.0009, mode 2 0.0014/0.0024/0.0012; a 32K prompt
0.074 and 0.072 (the hyper-connection is what matters there).
2026-10-01 08:40:02 +02:00
Niko1221 da95ad29d8 #282 follow-up: chunks above 8192 are opt-in (--prefill auto:16384 / auto:32768, or STRATA_PREFILL_AUTO_MAX)
A bigger chunk reads a long prompt in a different order of chunks (other expert loans, other rounding of the
batched path), so it stays out of the default: plain --prefill auto picks from 8192 down exactly as 0.1.31 did (the
PR's "no chunk past the context" rule, which also moved the choice below 8192 for small contexts, now applies only
to the new sizes). auto:N (16384 or 32768) or STRATA_PREFILL_AUTO_MAX with auto sets the ceiling; o.prefill_chunk
starts at that ceiling for the buffer estimates. Both kAutoChunks lists - plan_lend and the serve path's per-stage
fits() - take the same sizes under the same rule.
2026-10-01 08:39:58 +02:00
sergiywith 1e5e84d919 prefill auto: chunks up to 32768 (was 8192)
Every chunk streams again each expert it routes to, so a long prompt read in 8192-token chunks crossed PCIe with
nearly all experts of every layer four times per 32K. The auto list now starts at 32768 and 16384, still bounded by
the lend rule and by --max-context; a request lends only what its prompt needs, so short prompts are unaffected.

IQ2_XS, RTX 5090, 64K context, a 32K prompt (3 runs each): 5,624 -> 6,465 tok/s, 40K -> 18K experts streamed.
NVFP4 pack at 262K: 3,535 -> 5,201 tok/s.
2026-10-01 08:39:27 +02:00
Niko1221 6280f653c8 #281 follow-up: the saturated FP16 SwiGLU product keeps a NaN a NaN
fmaxf(NaN, -65504) is -65504, so #281's clamp turned a NaN product into -65504 and the prompt path went on with a
plausible value where it used to show the NaN (STRATA_DBG_NAN and the NaN checks find it there). isnan(x) ? x :
clamp(x): only +-inf (and finite values from 65520 up, which rounded to inf) saturate; everything else is unchanged.
2026-10-01 08:38:59 +02:00
sergiywith c517862ce2 numerics: saturate the prompt path's FP16 SwiGLU products; log1pf in the unfused GDN gate
- prefill/kernels.cu: the SwiGLU products that feed the prompt path's FP16 down projection are clamped to FP16's
  range (+-65504) before rounding. A token with a massive activation otherwise becomes inf there and NaN after the
  GEMM, where decode's q8_1 path (range ~8e6) stays finite.
- native_gdn_preprocess.cu: the unfused gate softplus used logf(1 + expf(v)), which returns 0 for v below ~-16
  (1 + e^v rounds to 1); the fused kernels, gdn.cu and verify_kernels.cu already use log1pf(expf(v)). The two paths
  now agree.

On ordinary prompts nothing moves: first-token KL to main 0, 0.00013, 0 (the run-to-run noise).
2026-10-01 08:38:35 +02:00
BlueKingMuch 4da8bfcbf9 ple: keep the SSD awake while rows are read (STRATA_SSD_KEEPALIVE)
Some SSDs (here a WD_BLACK SN7100 on Windows 11) drop into a power state
after ~250 ms without a command and stall the next reads 50-150 ms; the
NVMe idle timeouts of Windows' power plan do not change it. In decode that
hits the first read after a pause: at the start of a request, or after a
few rounds whose rows all came from the row cache. --ple-io ram, which
avoids the reads altogether, is not available on Windows.

The reader's worker now reads one page of the table (a different one each
time) when no read has gone out for 100 ms, until 60 s after the last
issue; then the SSD may sleep until the next request re-arms it. A
keep-alive read counts when it completes and stays out of the row-read
counters and latencies; the "ple io" line adds "SSD kept awake by N reads
(slowest X ms)". A keep-alive read that fails, or cannot be submitted,
turns the keep-alive off instead of failing the reader.
STRATA_SSD_KEEPALIVE=0 turns it off, =50 shortens the period;
STRATA_SSD_KEEPALIVE_WINDOW sets the window in seconds. Only with the I/O
worker (the default), not with --ple-sync-submit or --ple-io mmap/ram.

The reader's statistics are now copied and reset under the worker's lock
(snapshot()), since the worker may be counting a keep-alive read. The
selftest checks the period, the window, the re-arm by a ticket the row
cache serves, that rows and read counters are untouched, and that the
caller-thread mode has none.

A scan on that PC (reads after pauses of every length): 0 of 211 bursts
stalled after pauses under 200 ms, 35 of 73 after 200 ms and more, 0 of 68
of those with the keep-alive on.
2026-10-01 08:38:27 +02:00
Niko1221 9d0b28df54 #315 follow-up: its variants and switch renamed (plain / split / staged, STRATA_HC_SPLIT) - no clash with STRATA_GR_V3
main already has an opt-in "v3" hyper-connection read (#186, STRATA_GR_V3=1, another summation order); #315's
"v2"/"v3" and STRATA_HC_V2 read as the same thing. Now: the plain read (0.1.31's default), split (the norm per
token and stream) and staged (split + the down projection's activations staged by cp.async), kHcPlain/kHcSplit/
kHcStaged, gr_norm_split_kernel / gr_down_staged_kernel, and STRATA_HC_SPLIT: unset or 2 = the newest the card's
check accepts, 1 = at most split, 0 = the plain read. The check compares against the plain read (launch_multi with
kHcPlain) and the single-token read, never against what STRATA_GR_V3 selects; with STRATA_GR_V3=1 that opt-in read
still runs first, unchanged.
2026-10-01 08:38:22 +02:00
Niko1221 d85ff9dfe9 serve: model aliases - the config's aliases in /v1/models and accepted as model names (#297)
`"aliases": [...]` (or a comma-separated string) in strata-<model>.json: /v1/models lists the model with an
`aliases` field and each alias as its own entry (`alias_of`), /props?model= accepts them, and a request naming
an alias is answered under that name (OpenAI, streamed or not, and Anthropic). Any other name is still served
under the model's own name, as before; without aliases the responses are unchanged. Documented in DETAILS.md.
Test: ModelAliases (serve/test_server.py).
2026-10-01 08:37:46 +02:00
Niko1221 469bdaf891 #340 prototype: a split stage with room for its prompt buffers keeps them as its own (no loan, nothing streamed or refilled for it); STRATA_SPLIT_OWN_BUFFERS=0: every stage borrows
One GPU: unchanged (the rule is for layer splits only; the single-cache loan is computed exactly as before).
2026-10-01 08:37:18 +02:00
BlueKingMuch 2cf197f894 hc: the multi-token hyper-connection read split finer, checked bit for bit on each card (STRATA_HC_V2)
The multi-token read (verify windows, the MTP drafter) keeps v1's
arithmetic exactly - every sum over the same elements in the same order -
and only splits the work differently:
- the norm (v2, v3): one block per token and stream instead of one per
  token. Each thread visits the elements of its stream in the order it did
  in v1, so every sum of squares and rs is v1's; R' * w_norm is recomputed
  and scaled once (the same two roundings as v1's store and in-place
  scale). On an RTX 4080 SUPER: 0.58 -> 0.23 ms per 4-token round.
- the down projection (v3): v1's rows per block and lane order, but the
  activations arrive by cp.async in half-stream tiles, two in flight, in a
  bank-conflict-free two-plane layout, and the next tile's weights load
  while the current one is used. A tile is 160 = 5 x 32 chunks, so a lane
  still accumulates its chunks lane + 32 q in ascending order. Before sm_80
  and on HIP the staging is a plain copy (the same bits).

Verifier::init runs fused_gr_check once per card: v1, v2, v3 and the
single-token read on random bf16 weights and inputs (1..8 tokens, with and
without the pending write), every output compared bit for bit; the newest
variant that agrees runs on that card and the log says which. A card that
has not been checked runs v1. STRATA_HC_V2=0 keeps v1, =2 stops at v2.
The down kernels carry as many tokens per launch as the card's shared
memory allows (v3: 10 KB per token), as v1 did.

Rebased onto 0.1.31 (Niko1221): v1 is 0.1.31's default read unchanged - the TILEV 1280/2560 down kernel (sm_75's
small tile), the pre-Volta capacity rule, STRATA_EMULATE_CC - moved into down_chunk()/launch_multi(); main's opt-in
STRATA_GR_V3 path (#186, another summation order) still comes first in fused_gr_read_multi, and the card's check
calls launch_multi(1) directly, so it compares against the default read whatever STRATA_GR_V3 says.
2026-10-01 08:36:04 +02:00
Niko1221 348d65da7b #340 prototype: a layer split refills every stage's prompt loan concurrently (queue all, then wait); STRATA_REFILL_SERIAL=1: stage after stage; STRATA_TRACE times the loan and the refill
One stage: refill_one exactly as before (single-GPU unchanged). The same copies into the same slots either way.
2026-10-01 08:35:57 +02:00
Niko1221 bd84d0bc6d #284 follow-up: wait for the async commit on an event, at every point that touches the session afterwards
The commit graph writes the GDN state and conv tail, the QSA indexer's tail/dead/pooled/block_pos and the PLE
history; with #284 it may still run when commit() returns. Verifier::commit records commit_done_ after it, and
Verifier::wait_commit() synchronizes that event (not the device; a no-op when nothing is pending, recursing into a
layer split's later stages). A layer split's first stage keeps the synchronous commit (next_ set).

Audited, and where the wait now is:
- the serve decode loop's end (end of turn, max tokens, STOP/cancel): wait before the live session, the state
  fingerprint (STRATA_STATE_HASH) and the DONE line - so the next request's park (conversation snapshot capture),
  restore, checkpoint restore and zeroing never see a running commit;
- the prompt read by verify windows (serve): wait at its end - the batched prompt path (other streams), prompt
  checkpoints (cudaDeviceSynchronize already) and snapshots may follow;
- the start of each serve request: an event wait replaces #284's cudaDeviceSynchronize (covers error paths);
- the end of a generate run: wait right after the decode loop, before the stats and the dumps (#284 synchronized
  only later, after the routing dump);
- unchanged and safe: the next window (same stream), the MTP draft (its own stream; reads the window's final rows
  and its own K/V, which commit does not write), the adaptive tier thread (expert slots and residency tables only),
  STRATA_LOGPOS / STRATA_DUMP_FIRST_LOGITS (read run()'s head logits after run() synchronized), QUIT and /unload
  (the Verifier destructor synchronizes its streams before freeing).
2026-10-01 08:34:33 +02:00
Niko1221 2282393234 setup --backend hip: --vision cpu builds and uses the CPU image encoder (#304)
Setup forced images off on the AMD backend. hip_vision() now takes --vision cpu: build_engine_hip builds
tools/vision without CUDA beside the HIP engine (build_vision_cpu, again when its source changes) and records
vision/vision_src in BUILD.json; the start config is the CPU encoder's, as on NVIDIA. --vision yes/gpu stay off
with a hint (no HIP encoder build yet); without --vision nothing changes. Test: tools/test_setup_choices.py.
2026-10-01 08:34:07 +02:00
sergiywith a1e4fb4c80 Verify: the commit graph no longer waits; it overlaps the MTP draft
The commit's cudaStreamSynchronize held the host ~0.45 ms a round. The next window follows it on the same stream and
the drafter (its own stream) reads only this window's final rows and its own K/V, so a single-GPU session
(Verifier::set_commit_async) returns at once. Whatever touches the session from another stream or the host afterwards
synchronizes the device: the start of each --serve request (checkpoint restore, zeroing, the prompt path) and the end
of a generate run; checkpoint saves already did. A layer split keeps the wait, as does STRATA_COMMIT_SYNC=1.

IQ2_XS, 400-token answers, 3 runs each: 128.6 -> 135.8 tok/s decode (+5.6%), the same tokens. NVFP4 pack +0.8%.
2026-10-01 08:33:05 +02:00
Niko1221 3b7a49a6fd setup: Pascal (6.x) and Volta (7.0) with STRATA_EXPERIMENTAL_SM60=1: the community build with CUDA 12.x (#295)
The engine/CMake part shipped in 0.1.31. Setup now admits compute capability 6.x and 7.0 only when
STRATA_EXPERIMENTAL_SM60=1 is set (gpu_problem; without it the message says so), compiles the engine with
-DSTRATA_EXPERIMENTAL_SM60=ON for cards below sm_75 (engine_defs), and takes the newest CUDA 12.x toolkit
(find_nvcc(below=(13, 0)); CUDA 13 cannot build sm_60/sm_70) - stopping with a hint when there is none, or
when an RTX 50 card (CUDA 13) shares the model with such a card. Without the variable nothing changes.
Test: tools/test_setup_choices.py (mocked toolkits and cards).
2026-10-01 08:32:49 +02:00
Niko1221 272c16c767 #332 follow-up: the API request monitor is opt-in ("api_monitor": true / --api-monitor), off by default
It keeps the last 100 requests' prompts, answers and reasoning in memory. Off (the default): begin_request() keeps
nothing, the streams are not wrapped, GET /api/requests and /api-monitor answer 404. Every record write goes through
_note(), which does nothing without a record (the PR's direct self.record[...] writes in the stream error paths
would have failed with the monitor off). A client disconnect in do_POST is marked and then re-raised, as the server
handled it before. The start line says when it is on (and that anyone can read the history without an API key).
test_monitor: turns it on; MonitorOff checks the default; Anthropic requests ask for thinking with "thinking" (#278).
2026-10-01 08:32:01 +02:00
Ricardo Ambrogi 24d011807c serve: add standalone API request monitor 2026-10-01 08:31:48 +02:00
Niko1221 5a18f83a13 setup --gguf-dir: the shard count from the -of-N part of the name, not two (#305)
--gguf-dir built exactly <model>-00001-of-00002.gguf and -00002-of-00002.gguf, so a model split or named
differently (-00001-of-00003, Unsloth's ...-00001-of-00004.gguf) was not found. gguf_dir_shards() takes the
published name when it is there (unchanged for the existing models), else the one first shard in the folder
whose name has the size in it, and names shards 1..N from it as the engine and iq_pack do. Help text updated.
Test: tools/test_setup_choices.py (2- and 4-shard names).
2026-10-01 08:31:13 +02:00
Niko1221 354212421e setup: the CPU image encoder when the ready-made encoder has no code for the card (#331)
The release zip's strata-vision has no sm_75 code (BUILD.json vision_archs 86/89/120), so on an RTX 20 card
setup fell back to compiling the encoder, which fails on most Windows PCs. prebuilt_vision() now chooses the
CPU encoder (the same program without --gpu) with a warning; --build still compiles a GPU one. The next release
zip should build strata-vision with 75-real as well. Test: tools/test_setup_choices.py.
2026-10-01 08:30:06 +02:00
Niko1221 abfb05cfbe native packs: a BF16 (--compat-bf16) PLE key stays in the arena and is not replaced by the GGUF key (#326)
Since 0.1.25 NativeDense treats an IQ3_XXS/IQ4_XS/Q8_0 GGUF key as native, so for a native pack the loader
skipped the pack's key row and then refused the BF16 row iq_pack --compat-bf16 writes for OrcaRouter's IQ3_XXS
key ("native dense: incompatible matrix blk.1.ple_key.weight"). NativeDense::keep_unquantized_ple_key reads the
key row's code_bits from index.txt (WeightTable::index_code_bits) and keeps an unquantized row in the arena;
NativeDense::load does not upload the GGUF key over it, and the PLE uses the BF16 key as before 0.1.25. A
quantized key row (Q2_0, UD-Q4_K_XL packs) is unchanged; the GSQ packs' GGUF keys are BF16 (not eligible), so
they never reach this path. The reporter's fallback (Gen4536). Test: native_dense_ple_key_test (synthetic GGUF
+ pack, CUDA).
2026-10-01 08:29:03 +02:00
Niko1221 75780af235 mtp_fetch: a range read needs 206 + its Content-Range; pinned per-tensor sha256, corrupt installs fetched again (#327)
A mirror that ignores the Range header answers 200 with the shard's start (its JSON header); cut to the asked
length it passed the size check and the manifest hashed the wrong bytes, so the drafter accepted nothing. get()
now requires HTTP 206 with the requested Content-Range. The 31 MTP tensors' sha256 at the pinned revision
(de4b8e4) ship in SHA256 (two independent fetches agree; 29 re-read from the revision, the two expert tensors'
first/last 16 MiB): a wrong tensor is fetched again once, then refused. An inventory read from another
repository/revision is read again and its tensors are kept only when they hash right. `mtp_fetch.py verify`
(offline, cached by size/mtime) finds a corrupt install; setup then fetches the bad tensors again and rebuilds
the draft layer. From PR #324: a refused engine archive no longer keeps its zip and .done mark.
Tests: tools/test_mtp_fetch.py (fake checkpoint behind a mocked urlopen, a mirror that ignores Range),
test_setup_pins (refused archive).
2026-10-01 08:28:45 +02:00
Niko1221 d3bf912ea0 #334 follow-up: jsonschema is an optional, lazily imported package (no new hard dependency)
- The rebase keeps 0.1.31's requirements.txt install in setup.py and the Dockerfile: jsonschema is not added.
- serve/structured.py imports jsonschema on the first json_schema request, not at server start (a server without it
  started fine before #334 and still does). json_object never needs it; json_schema without it is checked to be one
  JSON object, and the server window says once how to get full schema validation.
- test_structured: the two schema-violation tests skip without jsonschema; new tests for the object-only fallback
  and for "import serve.server" not importing jsonschema. docs/DETAILS.md says it is optional.
2026-10-01 08:27:57 +02:00
Ricardo Ambrogi c87a9cb0ce serve: validate JSON response formats before delivery 2026-10-01 08:27:08 +02:00
Niko1221 bd9a1bc126 #333 follow-up: the unload keeps its terminate/kill fallback; EngineStuck instead of a catch-all RuntimeError
- StrataEngine.close() (now also the unload and the restart): QUIT and EOF, then terminate, then kill, each with 20 s
  as the unload had (#333 went from QUIT straight to kill after 10 s; an engine unpinning tens of GB can need more).
  A process that outlives all three raises EngineStuck and stays owned.
- do_POST caught every RuntimeError as a 503 - EngineDied, GpuBusy and ModelBusy are RuntimeErrors too, and any other
  failure was turned into a vague 503. Only EngineStuck is caught now (also around POST /unload, and the idle-unload
  thread survives it); EngineDied keeps its own answer.
- test_lifecycle: terminate before kill, EngineStuck, an EngineDied on load answered as such.
2026-10-01 08:26:28 +02:00
Ricardo Ambrogi 7f3a091895 serve: add lazy startup and reliable Windows model cleanup 2026-10-01 08:24:02 +02:00
Niko1221 a8d5a16c35 #321 follow-up: CORS only on /v1/* for configured origins; no own-page bypass for proxy headers
- The Origin check of /settings and the MCP tools let any page through when the request carried X-Forwarded-For,
  CF-Ray or CF-Connecting-IP (headers every reverse proxy or tunnel adds), and compared hosts without their ports
  (a page on another local port passed): any web page could then change the shared settings or run MCP tools with the
  user's rights. Removed. The Origin must be this server's host:port, or an origin in the config's new
  "trusted_origins" (the web app behind a proxy or tunnel); a proxy header alone opens nothing.
- CORS is opt-in: "cors_origins" in the config (an origin, a list, or "*"). Only /v1/* answers with
  Access-Control-Allow-Origin (the origin itself, or "*"), never /settings, /unload or the app's files; the
  preflight echoes the headers asked for ("*" does not cover Authorization). OPTIONS is answered 204 either way.
- X-Accel-Buffering: no on SSE stays. origins_of() checks both lists at start (scheme://host[:port], no paths;
  "*" only for cors_origins). docs/DETAILS.md; tests for the bypasses, the trusted origin, CORS off/on, the header.
2026-10-01 08:23:27 +02:00
BlitzenCatsandGemini 392e32c46b serve: add CORS preflight (OPTIONS), reverse-proxy header support, and X-Accel-Buffering for SSE
- Implement do_OPTIONS (204 No Content) and add Access-Control-Allow-* headers to allow web-based clients (Open WebUI, LibreChat, browser extensions) to query endpoints.
- Update _own_page to recognize reverse proxies and tunnels (Cloudflare Tunnel, Tailscale Funnel, Nginx) via X-Forwarded-Host, CF-Ray, and forwarded headers, preventing 403 CSRF false-positives when serving remotely.
- Add X-Accel-Buffering: no to SSE streaming responses to prevent reverse proxies from buffering real-time token generation.
- Add unit tests in serve/test_server.py covering CORS preflight, CORS headers, and reverse proxy origin checks.

Co-authored-by: Gemini <[email protected]>
2026-10-01 08:22:10 +02:00
Niko1221 ca85ebdee2 #278 follow-up: an Anthropic request with its own reasoning_budget_tokens still thinks
"thinking" absent meant no thinking, also for a request that gives reasoning_budget_tokens (#123), which asks for
thinking with a cap (serve.test_server's test_anthropic_stream failed: no thinking at all). Such a request renders
as before. test_anthropic_thinks_only_when_asked pins the five cases.
2026-10-01 08:18:19 +02:00
Niko1221 40eaa123d4 serve: a restarting engine is not alive until READY; context 0 is a 503 "starting" (#344)
restart() set ended=False before __init__, which zeroes max_context and blocks until READY, so a request in that
window skipped load() and failed with a 400 "leaves no room to answer in the context (0)". The engine is now
marked alive by __init__ once READY; prepare() answers 503 "the engine is starting" when max_context <= 0. The
output thread is bound to its own process and queue, so a killed engine's thread no longer marks its successor
dead or writes into its queue. Tests: RestartWindow (a fake strata --serve with a held READY).
2026-10-01 08:17:54 +02:00
Developer 6cd29d9850 serve: keep retained K/V through the cache capacity gate
park_current dropped the retained K/V whenever can_fit failed and fell
back to a full snapshot capture. The gate duplicates what make_room
decides immediately after, with harsher arithmetic: the slot term fires
when make_room enforces the same cap, and the byte term compares the
with-reuse estimate against headroom that ignores the parked image
replacing the retained buffers. make_room already implements the
stated policy - pressure drops the optional retained storage before
evicting any parked conversation (the priority the cache tests pin).
With a full cache the fallback buys nothing: the fresh estimate forces
the same or more eviction, plus a whole-snapshot recapture instead of
the growth - hundreds of ms per park at ~100K tokens where one long
conversation round-trips while short-lived conversations keep the
cache full.

The with-reuse estimate stays uncapped: it counts the retained buffers'
capacity and directories, and put() charges that same true size, so a
capped figure would under-evict and overfill the budget.
2026-10-01 08:15:27 +02:00
Pravesh Khatana b0711943b2 feat(mtp): add --coupled-draft and --no-coupled-draft CLI options for coupled speculative sampling
- Add set_coupled_draft() and coupled_draft_state() in include/strata/core/coupled_draft.hpp
- Add --coupled-draft and --no-coupled-draft CLI options to src/program/generate.cpp
- Default option to STRATA_SPEC_COUPLED environment variable for full backward compatibility
- Update usage() help text with option descriptions
2026-10-01 08:15:27 +02:00
Hozák Martin 43cd140ecd bench: BF16 GEMV via Turing tensor cores on sm_75 - measured negative
Isolated benchmark (no inference-path changes) comparing the production
bf16_gemv warp kernel against an mma.sync.m16n8k8 tensor-core variant with
BF16->FP16 conversion, on the real decode GEMV shapes (n_in = 2560). Three
TC variants (W preconverted / inline conversion / fused output) and a full
k-split occupancy sweep; parity against an fp64 reference.

Result: the TC variant is 1.3-2.2x slower on every real shape even at its
best config - the warp kernel is already bandwidth-bound (374 GB/s) and a
single-token GEMV wastes 15/16 of the m16 MMA tile. Keeping the warp
kernel; the benchmark is checked in so this does not have to be measured
twice.
2026-10-01 08:15:27 +02:00
Zac Chen 4e1a7c5982 verify: profile columns stop wrapping (unsigned stamp deltas) and the hc-read second half stops overwriting the next layer's base stamp
kProfPer 32 -> 33: fused_gr_read_multi writes three stamps per half and the second
half's base was 30, so its up-stamp landed at slot 32 = (l+1)*32 + 0, the next layer's
stage-0 base.  Derived columns (gap/head/hc0) also wrapped to ~1.8e19 ns whenever a
slot was unstamped, printing ~1e14 ms per window.  A delta now counts only when both
stamps are nonzero and monotonic; the hc0 split replaces the single hc-read0 column
only when the split chain is complete.  Metrics-only: no computed model value changes.
Reason recorded in PORTING.md 19.
2026-10-01 08:15:27 +02:00
sergiywith 7f2c76e18d iq_pack: keep experts.bin only when its blobs match the GGUF
An existing experts.bin was kept whenever its size matched the layout. A different checkpoint with the same geometry
(a re-quantization or a fine-tune packed into the same folder) has the same size, so the engine silently served the
old experts with the new model's dense weights.

Before keeping the file, the first and last blobs of the first, middle and last layers are now compared with the
GGUF; a mismatch rewrites it. The check reads six blobs, so an unchanged pack is still reused at once.

Test: test_experts_bin_is_kept_only_with_this_ggufs_blobs packs two GGUFs of the same geometry into one folder (it
fails on the previous code).

Rebased onto 0.1.31 (Niko1221): 0.1.31 already refuses an experts.bin whose sidecar (experts.bin.src.json: shard
names and sizes, the hash of native_experts.txt) does not match, so the six-blob comparison runs only when the
sidecar says "reuse" - it catches the case the sidecar cannot (a re-quantized or fine-tuned checkpoint under the same
file names and sizes). It reads each of the six expert blobs on its own instead of concatenating the whole layer.
The test packs the second model under the first one's file name, so it reaches the new check.
2026-10-01 08:15:22 +02:00
Niko1221 d672a5f443 #287 follow-up: setup run again without --draft-vocab keeps the model's earlier choice
The full setup rewrites the model's config and refreshed the MTP folder's subset with `a.draft_vocab or "cjk"`, so
running it again without the flag put the CJK subset back over a cyrillic (or en) choice, and dropped draft_vocab from
the config. saved_draft_vocab() reads the choice the config holds; the flag still wins. tools/test_setup_draft_vocab.py.
2026-10-01 08:14:00 +02:00
sergiywith 79aa0bb11b setup: --draft-vocab cyrillic - the English/code subset plus the whole Cyrillic script
The shipped draft subsets hold 142 of the vocabulary's 18,580 Cyrillic tokens, so an answer in Ukrainian, Russian,
Bulgarian or Serbian drafted almost nothing. data/draft_vocab_cyrillic.bin is draft_vocab_en.bin plus every token
whose text is Cyrillic (58,963 ids), built with the repository's own tool:
  python tools/draft_vocab.py --gguf <IQ2_XS shard 1> --base data/draft_vocab_en.bin --add cyrillic

- tools/draft_vocab.py: a "cyrillic" script (U+0400-052F, 1C80-1C8F, 2DE0-2DFF, A640-A69F).
- setup.py: --draft-vocab cyrillic; docs/DETAILS.md.

On the NVFP4 fork: 1.4 -> 2.1 tokens per round, 83 -> 109 tok/s in Ukrainian answers, English unchanged.
2026-10-01 08:13:17 +02:00
sergiywith fcef1b0dbd strata-vision: --flash-attn on|off|auto; on the CPU no CUDA context, no warm-up, a thread per core
- --flash-attn on|off|auto (mtmd's flash_attn_type): flash attention keeps K and V in FP16. On the NVFP4 fork the
  vision tower's output was 11.2% off with it on the GPU; FP32 attention (CPU) 0.1%.
- On the CPU a CUDA build keeps the GPU unseen (CUDA_VISIBLE_DEVICES=-1 before the runtime starts): it otherwise
  opened a context there, 0.4-0.7 GB of VRAM the expert cache loses.
- On the CPU without --threads: one thread per core (mtmd's default is 4); no warm-up encode (~6 s of start).
- serve/server.py: relative exe/mmproj/model paths in the vision config are the config's cwd's, as the engine's are.
2026-10-01 08:12:47 +02:00
Niko1221 7f49b76680 #278 follow-up: the request-line follower starts once per log; its time field is prompt_read, not ttft
- StrataEngine.restart() runs __init__ again (after a crash, an idle unload's reload), which started one more
  STRATA_REQUEST_LINES follower each time and printed every request line once per restart: a follower is started
  once per log path (the log is opened for append, so the first follower keeps reading the same file).
- The field the follower printed as "ttft" is the engine's prompt read time (the new tokens only, without the queue,
  the template, the first decode step): it is named prompt_read.
2026-10-01 08:12:35 +02:00
sergiywith 4c2cd14f5f serve: /v1/messages/count_tokens, Anthropic thinking only when asked, STRATA_REQUEST_LINES, a relative exe
Found by running Claude Code against the server (claude -p, three tool turns) and by starting a config with a
relative engine path:

- POST /v1/messages/count_tokens answered 404; Claude Code asks it for its context figures. It now renders and
  tokenizes the request exactly as /v1/messages would, without running the model.
- A /v1/messages request with no "thinking" (and no effort) got the template's default, which thinks: Claude Code's
  helper calls (a ~60-token session title) came back with a thinking block and no text. Anthropic's thinking is
  opt-in, so such a request now renders with enable_thinking false. A config's reasoning_effort still applies;
  "thinking" set, or an effort, behave as before.
- STRATA_REQUEST_LINES=1: a thread follows the engine log and re-states each request's summary line on stdout, for
  supervisors that only see the server's stdout (a tray, llama-swap). Off by default.
- A relative "exe" in the config failed in Windows' CreateProcess (WinError 2): it is made absolute against the
  config's cwd, as the engine's cwd already is.

Tests: count_tokens equals the input_tokens /v1/messages reports for the same request; the request line pattern
parses the engine's summary. python -m unittest serve.test_server: 44 OK.
2026-10-01 08:12:11 +02:00
Niko1221 158e85bf43 #289 follow-up: a placeholder GPU name when the properties query fails; HIP builds compile (no device_code_error probe)
- generate.cpp: the startup line names "(an unnamed GPU)" when cudaGetDevice/cudaGetDeviceProperties fail (the
  error is cleared), and the "no code for" message uses the same name; a HIP build prints the gfx architecture
  instead of a CUDA compute capability.
- device.cu: device_code_error() returns "" on HIP - cudaFuncAttributes/cudaFuncGetAttributes have no mapping in
  hip_compat, and gpu_arch_problem() already stops a HIP run on an architecture the build lacks.
- fused_gr.cu: STRATA_EMULATE_CC also answers the two compute-capability queries there (small tile, the sm_70
  capacity rule), as the PR's description promises; with the variable unset nothing changes.
2026-10-01 08:11:39 +02:00
sergiywith a606e3a966 Start: name the GPU and stop at once when the build has no code for it; STRATA_EMULATE_CC for tests
- generate.cpp: before the expert arena loads, the engine names the card and its compute capability and checks that
  the build has code for it (device_code_error: cudaFuncGetAttributes on a kernel). A binary built for other GPUs
  used to fail at the first kernel, after the whole arena had loaded; now it stops in ~0.3 s and says why.
- include/strata/core/emulate.hpp: STRATA_EMULATE_CC=75|80|86|89 makes the host's device queries (compute
  capability, shared memory per block) answer as that generation, for a PTX build of it JIT-compiled on a newer
  card - ggml's MMQ config, the QSA kernels' choice, fused_gr's shared memory. Tests only.
- STRATA_QSA_WARP=1|select|attn: the pre-sm_80 QSA kernels on any card (A/B).
2026-10-01 08:11:23 +02:00
sergiywith aea6e45c64 diagnostics: STRATA_DUMP_FIRST_LOGITS writes the first generated token's logits
STRATA_DUMP_FIRST_LOGITS=<file>: the logits of the first window's first position (the token the prompt predicts),
n_vocab float32, written once per run. Two engine variants run on the same prompt then give a first-token KL
divergence: how far a prompt-path change moves the model's distribution, not only whether its top token changed.
On a layer split the last stage's verifier writes it.
2026-10-01 08:09:42 +02:00
Niko1221 cfb1866ec5 HIP (S6): router_top10.cu - drop a nested #if that left the HIP block unterminated 2026-10-01 08:07:46 +02:00
Niko1221 a2be1670bb HIP (S6): router_top10 without its serial parts on AMD, bitwise the same (default on HIP; STRATA_HIP_ROUTER_OLD=1: old)
On RDNA4 the router took 39 us per call in decode (16% of the decode GPU time): 512 dependent FP64 adds, ten
selection passes with 20 block barriers, a renormalisation re-reading its weights from global memory. The sum's only
use is inv = (float)(1.0 / sum): all terms are positive with the largest exactly 1, so the serial sum and a tree sum
are within 2^-43 of each other; when the bracket tree*(1 -+ 2^-40) maps to one float, that float is the serial sum's
(1/x and the cast are monotone), otherwise thread 0 runs the serial sum as before. The selection runs on wave 0 from
registers (the argmax with the lowest index on ties is unique), the renormalisation from registers in rank order.
hip_router_fast checks ids and weights bitwise against the portable kernel (65,536 rows with ties, near-ties, NaN
rows, the forced serial path; and 256 experts / k 8). CUDA builds unchanged (HIP-only code).
2026-10-01 07:24:19 +02:00
Niko1221 73ba449679 HIP (S6): RDNA4 matrix-core prompt attention for int8 KV, opt-in (STRATA_HIP_WMMA=1, gfx12 only)
The v2 tensor-core design with gfx12's v_wmma_f32_16x16x16_f16: one block per (query, KV head), wave w owns
dims [64w, 64w+64) (int8 scale group w), q and p split into FP16 hi + lo parts, int8 codes exact as FP16, scales in
FP32, the four groups' q.k partials added in a fixed order, online softmax over 32-cell chunks. Masked
(non-resident) cells get no weight, as in the decode kernel. FP32-level accuracy, not bitwise equal to the default
kernel, hence opt-in. hip_prompt_attn_wmma = qsa_prompt_attn_parity built for HIP (skips off gfx12).
NVIDIA builds unchanged (all new code is under __HIPCC__ / the HIP test section).
2026-10-01 06:44:23 +02:00
146 changed files with 19456 additions and 1219 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: strataengine
+2
View File
@@ -37,6 +37,7 @@ td.log
/strata-*.json
/strata-*.log
/build-strata.bat
/.strata-mcp/
# image encoder (setup.py --vision): build output and the generated build script
build-vision*/
@@ -49,3 +50,4 @@ build-vision.bat
*.done
/third_party/*.zip
/.rocm-win/
+23
View File
@@ -0,0 +1,23 @@
# AGENTS.md
Strata runs the Qwen3.8-Flash-Next mixture-of-experts model (and its Coder, Swift 1.5 and Unsloth variants) on a
normal PC: one NVIDIA or AMD graphics card plus system RAM, on Windows or Linux. It has a C++/CUDA/HIP engine
(`src/`, `include/`), a Python server with an OpenAI- and Anthropic-compatible API and a web app (`serve/`), and a
one-click installer (`setup.py`, started by `START-HERE.bat` / `setup.sh`).
## Installing Strata for a user
Follow **[docs/AI_SETUP.md](docs/AI_SETUP.md)**: check the PC, pick the model by RAM, run setup non-interactively,
start and verify the server, and connect the user's apps. Never expose the server beyond `127.0.0.1` without
`--api-key`. As an alternative to shell commands, Strata's MCP server ([docs/MCP_SERVER.md](docs/MCP_SERVER.md))
offers the same steps as tools.
## Working on the code
- How the engine works, every measured number, the API and all settings: [docs/DETAILS.md](docs/DETAILS.md) and
the [paper](docs/paper/Strata-Paper.pdf).
- AMD (HIP) build and validation: [docs/AMD_HIP.md](docs/AMD_HIP.md); multi-GPU: [docs/MULTI_GPU.md](docs/MULTI_GPU.md).
- Setup's own tests run without a GPU or downloads: `python tools/test_setup_<name>.py` (for example
`tools/test_setup_amd.py`, `tools/test_setup_choices.py`).
- Keep the docs' style: plain words, measured numbers with what they were measured on, no claims without a
measurement.
+125 -6
View File
@@ -8,7 +8,7 @@
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
# and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
project(strata VERSION 0.1.31 LANGUAGES CXX)
project(strata VERSION 0.1.36 LANGUAGES CXX)
add_compile_definitions(STRATA_VERSION="${PROJECT_VERSION}")
# issue #31: release builds keep a PDB beside the exe (not shipped in it), so a minidump from a stalled engine can
# be read back to the exact source line
@@ -33,8 +33,13 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
endif()
option(STRATA_ENABLE_CUDA "Build the CUDA targets (Phase 2+)" OFF)
option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201)" OFF)
option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1030 unvalidated)" OFF)
option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
# CUDA: MMQ prompt kernels for Q4_K / Q5_K / Q5_1 (Unsloth's UD-Q4_K_XL experts). Off by default: the engine loads
# every kernel when it starts (CUDA_MODULE_LOADING=EAGER), so these took ~5 MB of VRAM - 1-3 expert slots - from every
# model, and the UD-Q4_K_XL prompt is SSD-bound with or without them (docs/UNSLOTH_Q4.md)
option(STRATA_MMQ_KQUANTS "Build the CUDA MMQ prompt kernels for Q4_K / Q5_K / Q5_1 experts" OFF)
option(STRATA_ORCA_Q4KS_MMQ "Build the GGML Q5_0 MMQ instance for OrcaRouter Q4_K_S" OFF)
if(STRATA_PREFILL_MMQ AND NOT STRATA_ENABLE_HIP)
message(FATAL_ERROR "STRATA_PREFILL_MMQ requires STRATA_ENABLE_HIP=ON")
endif()
@@ -269,6 +274,15 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(qsa_prompt_attn_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(qsa_prompt_attn_parity PRIVATE strata_kernels CUDA::cudart)
endif()
# the QSA selection (block scores on the warp kernel and the tensor-core one, top-k): a GPU bench with a
# scorer-vs-scorer comparison and an FP64 accuracy gate (synthetic, no model); the ctest runs a 32K context under a
# 262144-cell capacity (the top-k dispatch follows the capacity) and exits non-zero on a failed gate
if((STRATA_BUILD_TESTS OR STRATA_PARITY_PROMPT_ATTN) AND STRATA_ENABLE_HIP)
add_executable(qsa_select_bench src/kernels/qsa_select_bench.cpp)
set_source_files_properties(src/kernels/qsa_select_bench.cpp PROPERTIES LANGUAGE HIP)
target_link_libraries(qsa_select_bench PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME qsa_select_bench COMMAND qsa_select_bench 32768 256 3 262144)
endif()
# plan v0.3 P6: llama.cpp's block layouts and codebook grids for the i-quant formats (MIT, third_party/ggml)
target_include_directories(strata_kernels PRIVATE third_party/ggml)
# Match the pinned llama.cpp CUDA arithmetic contract for this adapter only.
@@ -336,6 +350,14 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_test(NAME file_expert_source_test COMMAND file_expert_source_test)
set_tests_properties(file_expert_source_test PROPERTIES TIMEOUT 60)
endif()
# #477: the learned expert profile (--expert-profile-save): its order and make_profile.py's file format (CPU only)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/core/expert_profile_save_test.cpp")
enable_testing()
add_executable(expert_profile_save_test tests/core/expert_profile_save_test.cpp)
target_link_libraries(expert_profile_save_test PRIVATE strata_engine)
add_test(NAME expert_profile_save_test COMMAND expert_profile_save_test)
set_tests_properties(expert_profile_save_test PROPERTIES TIMEOUT 60)
endif()
if(STRATA_BUILD_TESTS AND STRATA_NATIVE_EXPERTS)
# native_experts.txt v2-v4 (the real packs' copies and the per-role shard column) and the arena filled from
# synthetic GGUF shards at the real expert dimensions
@@ -343,6 +365,16 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_link_libraries(expert_layout_test PRIVATE strata_engine)
add_test(NAME expert_layout_test COMMAND expert_layout_test ${CMAKE_CURRENT_SOURCE_DIR}/tests/data/native_experts)
set_tests_properties(expert_layout_test PROPERTIES TIMEOUT 60)
# #326: a native pack's BF16 (--compat-bf16) PLE key stays in the arena; a quantized key row loads as before
add_executable(native_dense_ple_key_test tests/core/native_dense_ple_key_test.cpp)
target_link_libraries(native_dense_ple_key_test PRIVATE strata_engine)
add_test(NAME native_dense_ple_key_test COMMAND native_dense_ple_key_test)
set_tests_properties(native_dense_ple_key_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60)
# #369: per-layer admission on a sized (native pack) cache starts each layer in its own range
add_executable(expert_cache_per_layer_test tests/core/expert_cache_per_layer_test.cpp)
target_link_libraries(expert_cache_per_layer_test PRIVATE strata_engine)
add_test(NAME expert_cache_per_layer_test COMMAND expert_cache_per_layer_test)
set_tests_properties(expert_cache_per_layer_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 60)
endif()
if(STRATA_BUILD_CONVERSATION_TESTS)
add_executable(conversation_snapshot_test src/core/conversation_snapshot_test.cpp)
@@ -402,6 +434,15 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_test(NAME hip_handoff COMMAND hip_handoff)
list(APPEND _strata_hip_tests hip_handoff)
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/hip/mapped_alias.cpp")
# #325: reports what a hipHostGetDevicePointer alias is on this stack (on Windows: the host pointer, and a
# D2D copy into it does not land - hip_handoff's timeout there). A diagnosis: exits 0 whenever it ran.
add_executable(hip_mapped_alias tests/hip/mapped_alias.cpp)
target_link_libraries(hip_mapped_alias PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_mapped_alias COMMAND hip_mapped_alias)
set_tests_properties(hip_mapped_alias PROPERTIES SKIP_RETURN_CODE 77)
list(APPEND _strata_hip_tests hip_mapped_alias)
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/hip/native_qsa_score.cpp")
add_executable(hip_native_qsa_score tests/hip/native_qsa_score.cpp)
target_link_libraries(hip_native_qsa_score PRIVATE strata_kernels strata_hip_runtime)
@@ -414,6 +455,17 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_test(NAME hip_prefill_native_batch COMMAND hip_prefill_native_batch)
list(APPEND _strata_hip_tests hip_prefill_native_batch)
endif()
# S6: the AMD fast router against the portable kernel, bitwise
add_executable(hip_router_fast tests/hip/router_fast.cpp)
target_link_libraries(hip_router_fast PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_router_fast COMMAND hip_router_fast)
list(APPEND _strata_hip_tests hip_router_fast)
# S6: the RDNA4 matrix-core prompt attention (STRATA_HIP_WMMA) against the FP32 kernel and FP64; skips off gfx12
add_executable(hip_prompt_attn_wmma src/kernels/qsa_prompt_attn_parity.cpp)
target_link_libraries(hip_prompt_attn_wmma PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_prompt_attn_wmma COMMAND hip_prompt_attn_wmma 32768 2048 3)
set_tests_properties(hip_prompt_attn_wmma PROPERTIES TIMEOUT 600 SKIP_RETURN_CODE 77)
list(APPEND _strata_hip_tests hip_prompt_attn_wmma)
if(STRATA_HIPBLASLT_AVAILABLE AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/hip/prefill_hipblaslt_gemm.cpp")
add_executable(hip_prefill_hipblaslt_gemm tests/hip/prefill_hipblaslt_gemm.cpp)
target_include_directories(hip_prefill_hipblaslt_gemm PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src/prefill")
@@ -536,6 +588,14 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
set_tests_properties(sampler_parity_one_block PROPERTIES ENVIRONMENT "STRATA_OLD_SAMPLER=0;STRATA_SAMPLER_ONE_BLOCK=1")
add_test(NAME sampler_parity_old COMMAND sampler_parity --selftest)
set_tests_properties(sampler_parity_old PROPERTIES ENVIRONMENT "STRATA_OLD_SAMPLER=1;STRATA_SAMPLER_ONE_BLOCK=0")
# S19: the decode QSA top-k and the greedy argmax on thread-block clusters (sm_90+) vs the one-CTA kernels, bitwise,
# captured in a graph too (GPU, synthetic, no model; skips below sm_90); --bench: per-call times
if(STRATA_ENABLE_CUDA)
add_executable(decode_cluster_parity src/kernels/decode_cluster_parity.cpp)
target_include_directories(decode_cluster_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(decode_cluster_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME decode_cluster_parity COMMAND decode_cluster_parity --selftest)
endif()
add_executable(rope_parity src/kernels/rope_parity.cpp)
target_include_directories(rope_parity PRIVATE ${_strata_gpu_include_directories})
@@ -758,6 +818,9 @@ set(STRATA_GGML_DIR "" CACHE PATH "llama.cpp checkout to take ggml from (empty:
if(STRATA_PREFILL_MMQ AND NOT STRATA_NATIVE_EXPERTS)
message(FATAL_ERROR "STRATA_PREFILL_MMQ requires STRATA_NATIVE_EXPERTS=ON for GGML sources")
endif()
if(STRATA_ORCA_Q4KS_MMQ AND NOT STRATA_NATIVE_EXPERTS)
message(FATAL_ERROR "STRATA_ORCA_Q4KS_MMQ requires STRATA_NATIVE_EXPERTS=ON for GGML sources")
endif()
if(STRATA_NATIVE_EXPERTS)
enable_language(C)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
@@ -806,6 +869,11 @@ if(STRATA_NATIVE_EXPERTS)
endif()
target_link_libraries(strata_kernels_cpu PUBLIC ggml-cpu ggml-base)
target_compile_definitions(strata_kernels_cpu PUBLIC STRATA_NATIVE_EXPERTS=1)
set(_strata_mmq_types q2_0 iq2_xxs iq2_xs iq2_s iq3_xxs iq3_s iq4_nl iq4_xs q8_0)
# #296: OrcaRouter Q4_K_S's Q5_0 experts (Q4_K and Q5_1 come with STRATA_MMQ_KQUANTS on CUDA, S5's branch)
if(STRATA_ORCA_Q4KS_MMQ)
list(APPEND _strata_mmq_types q5_0)
endif()
if(STRATA_ENABLE_CUDA)
# prompt-speed plan step 2b: llama.cpp's MMQ kernels (ggml-cuda, MIT) for the prompt path's experts, compiled
# from the same checkout as ggml above - only mmq.cuh's instances for the packs' types and the quantizer, with
@@ -816,8 +884,18 @@ if(STRATA_NATIVE_EXPERTS)
set(_strata_gg ${strata_llamacpp_SOURCE_DIR}/ggml)
endif()
set(_strata_mmq ${_strata_gg}/src/ggml-cuda)
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/ggml_cuda_host.cu ${_strata_mmq}/quantize.cu)
foreach(_t q2_0 iq2_xxs iq2_xs iq2_s iq3_xxs iq3_s iq4_nl iq4_xs q8_0) # q8_0: the drafter's matrices (E-9)
# #136: the Q2_0 pack's prompt experts on Strata's own int8 kernels (moe_fused.cu, opt-in STRATA_PF_FUSED=1; CUDA
# only: the HIP branch below keeps MMQ, and an sm_75 device or an sm_75-only build reports them unavailable)
add_library(strata_mmq STATIC src/prefill/moe_mmq.cu src/prefill/moe_fused.cu src/prefill/moe_fused_iq.cu
src/prefill/ggml_cuda_host.cu ${_strata_mmq}/quantize.cu)
# q8_0: the drafter's matrices (E-9); STRATA_ORCA_Q4KS_MMQ adds q5_0 (#296, in _strata_mmq_types), and
# STRATA_MMQ_KQUANTS q4_k q5_k q5_1, the experts of Unsloth's UD-Q4_K_XL (docs/UNSLOTH_Q4.md; the dispatch
# follows eddoursul/Strata 7706c1b)
set(_strata_mmq_cuda_types ${_strata_mmq_types})
if(STRATA_MMQ_KQUANTS)
list(APPEND _strata_mmq_cuda_types q4_k q5_k q5_1)
endif()
foreach(_t IN LISTS _strata_mmq_cuda_types)
target_sources(strata_mmq PRIVATE ${_strata_mmq}/template-instances/mmq-instance-${_t}.cu)
endforeach()
target_include_directories(strata_mmq PRIVATE ${_strata_gg}/include ${_strata_gg}/src ${_strata_mmq}
@@ -827,8 +905,33 @@ if(STRATA_NATIVE_EXPERTS)
target_compile_options(strata_mmq PRIVATE $<$<COMPILE_LANGUAGE:CUDA>:-Xcompiler=/bigobj>)
endif()
target_link_libraries(strata_mmq PUBLIC ggml-base ${_strata_gpu_runtime_target})
if(STRATA_MMQ_KQUANTS)
target_compile_definitions(strata_mmq PRIVATE STRATA_MMQ_KQUANTS=1)
endif()
if(STRATA_ORCA_Q4KS_MMQ)
target_compile_definitions(strata_mmq PRIVATE STRATA_ORCA_Q4KS_MMQ=1)
endif()
target_link_libraries(strata_prefill PUBLIC strata_mmq)
target_compile_definitions(strata_prefill PUBLIC STRATA_PREFILL_MMQ=1)
target_compile_definitions(strata_prefill PUBLIC STRATA_PREFILL_MMQ=1 STRATA_PREFILL_FUSED=1)
if(STRATA_BUILD_TESTS)
# #136: the fused int8 experts against a double-precision reference and the MMQ path (exit 77 below sm_80)
add_executable(prefill_fused_moe_test tests/cuda/prefill_fused_moe_test.cpp)
target_link_libraries(prefill_fused_moe_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_fused_moe_test COMMAND prefill_fused_moe_test)
set_tests_properties(prefill_fused_moe_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 300)
# #136: the same for the native packs' formats (IQ2_XXS IQ2_XS IQ2_S IQ3_XXS IQ3_S IQ4_XS, down Q2_0 IQ4_NL)
add_executable(prefill_fused_iq_test tests/cuda/prefill_fused_iq_test.cpp)
target_link_libraries(prefill_fused_iq_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_fused_iq_test COMMAND prefill_fused_iq_test)
set_tests_properties(prefill_fused_iq_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 600)
endif()
if(STRATA_BUILD_TESTS AND STRATA_MMQ_KQUANTS)
# the UD-Q4_K_XL expert formats through MMQ against ggml's dequantized weights (synthetic, a few seconds)
add_executable(prefill_mmq_kquant_test tests/cuda/prefill_mmq_kquant_test.cpp)
target_link_libraries(prefill_mmq_kquant_test PRIVATE strata_mmq ggml-base ${_strata_gpu_runtime_target})
add_test(NAME prefill_mmq_kquant_test COMMAND prefill_mmq_kquant_test)
set_tests_properties(prefill_mmq_kquant_test PROPERTIES TIMEOUT 120)
endif()
elseif(STRATA_PREFILL_MMQ)
# Compile only the pinned GGML MMQ and activation-quantizer sources used by prefill. This uses
# common.cuh's native HIP path without enabling a second GGML HIP backend.
@@ -845,7 +948,7 @@ if(STRATA_NATIVE_EXPERTS)
src/prefill/moe_mmq.cu
src/prefill/ggml_cuda_host.cu
"${_strata_mmq}/quantize.cu")
foreach(_t q2_0 iq2_xxs iq2_xs iq2_s iq3_xxs iq3_s iq4_nl iq4_xs q8_0)
foreach(_t IN LISTS _strata_mmq_types)
list(APPEND _strata_mmq_sources "${_strata_mmq}/template-instances/mmq-instance-${_t}.cu")
endforeach()
set_source_files_properties(${_strata_mmq_sources} PROPERTIES LANGUAGE HIP)
@@ -854,6 +957,9 @@ if(STRATA_NATIVE_EXPERTS)
PRIVATE "${_strata_gg}/include" "${_strata_gg}/src" "${_strata_mmq}"
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_compile_definitions(strata_mmq PRIVATE GGML_USE_HIP=1)
if(STRATA_ORCA_Q4KS_MMQ)
target_compile_definitions(strata_mmq PRIVATE STRATA_ORCA_Q4KS_MMQ=1)
endif()
target_link_libraries(strata_mmq PUBLIC ggml-base hip::host roc::hipblas)
target_link_libraries(strata_prefill PUBLIC strata_mmq)
target_compile_definitions(strata_prefill PUBLIC STRATA_PREFILL_MMQ=1)
@@ -884,11 +990,24 @@ if(STRATA_NATIVE_EXPERTS)
set_tests_properties(native_expert_parity_${_name} PROPERTIES TIMEOUT 120)
endforeach()
add_test(NAME native_expert_parity_q5_1_min COMMAND native_expert_parity --q5_1-min)
# #290: the BF16 token embedding (--embd-gguf) gathered and dequantized bit for bit
add_test(NAME native_expert_parity_bf16_embd COMMAND native_expert_parity --bf16-embd)
# a pair without GPU kernels (Q6_K gate/up) is refused by native_expert_supported, not run
add_test(NAME native_expert_parity_refuses_q6_K COMMAND native_expert_parity --synthetic q6_K/q8_0)
set_tests_properties(native_expert_parity_refuses_q6_K PROPERTIES WILL_FAIL TRUE)
endif()
endif()
# #296's real-weight checks (OrcaRouter Q4_K_S files; docs/ORCA_Q4_K_S.md) - test programs, not part of a release
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS)
add_executable(ple_q5_parity src/kernels/ple_q5_parity.cpp)
target_link_libraries(ple_q5_parity PRIVATE strata_kernels strata_artifact ggml-base ${_strata_gpu_runtime_target})
add_executable(q5_projection_parity src/kernels/q5_projection_parity.cpp)
target_include_directories(q5_projection_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(q5_projection_parity PRIVATE strata_kernels strata_artifact ggml-base ${_strata_gpu_runtime_target})
# #291: the FP8 PLE table (tools/ple_fp8_pack.py) through PleTable vs the checkpoint's own values
add_executable(ple_fp8_parity src/kernels/ple_fp8_parity.cpp)
target_link_libraries(ple_fp8_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
endif()
endif()
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro/hit_cpu_order_parity.cu")
+122 -209
View File
@@ -1,272 +1,185 @@
<h1 align="center">Strata</h1>
<p align="center"><b>Run a 125-billion-parameter AI model on a normal gaming PC</b><br>
one NVIDIA card (12-24 GB) + 64 GB of RAM · Windows or Linux · one click to install</p>
<p align="center"><b>Run a 125-billion-parameter AI model on your own gaming PC</b><br>
NVIDIA or AMD graphics card (12 GB or more) · Windows or Linux · free and open source</p>
<p align="center"><a href="https://github.com/Niko1221/Strata/releases/download/v0.1.10/Pagoda.mp4"><img src="docs/media/pagoda-preview.webp" width="720" alt="A voxel pagoda garden that Strata's model wrote, running in the browser"></a><br>
<sub>A voxel pagoda garden, 1 shot prompt running on an RTX 5070 with Strata (IQ3_S, 128K context) ·
<a href="https://github.com/Niko1221/Strata/releases/download/v0.1.10/Pagoda.mp4">full video (49 s)</a></sub></p>
Strata runs **[Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next)** - a large, smart AI model that
normally needs a server - on your own PC. It writes its answers at **60-95 tokens per second** (a token is about ¾
of a word): faster than you can read.
- **Free and open source.**
> **Jump to:** [How fast?](#how-fast-is-it) · [Which model?](#which-model-should-i-pick) · [Install](#install) ·
> [Using it](#using-it) · [Problems?](#something-went-wrong) · [How it works](#how-does-it-work) ·
> [All the details](docs/DETAILS.md)
---
normally needs a server - on a normal PC. It chats, writes code, reads pictures and works with your apps and coding
agents, and nothing leaves your PC.
## How fast is it?
Measured on an RTX 5070 (12 GB), a Ryzen 5 7600 and 64 GB of RAM:
Measured on two ordinary gaming PCs. "Writes answers" is how fast the reply appears in a short chat; "reads your
prompt" is how fast it takes in what you send (a 32K-token document, code or chat history). A token is about ¾ of a
word, so 60 tokens per second is faster than you can read.
| Size | Writes answers (short chat) | Writes answers (128K context) | Reads your prompt |
| --- | ---: | ---: | ---: |
| **Q2_0** | 93 tokens/s | 74 tokens/s | 2,170 tokens/s |
| **IQ2_XS** | 79 tokens/s | 63 tokens/s | 2,090 tokens/s |
| **IQ3_XXS** | 62 tokens/s | 49 tokens/s | 1,750 tokens/s |
| **IQ3_S** | 53 tokens/s | 46 tokens/s | 1,620 tokens/s |
| **Coder** (IQ1_M) | 55 tokens/s | 43 tokens/s | 2,180 tokens/s |
<table>
<tr><th>NVIDIA: RTX 5070 (12 GB), Ryzen 5 7600, 64 GB RAM</th><th>AMD: RX 9070 XT (16 GB), Ryzen 9 3900X, 47 GB RAM</th></tr>
<tr><td>
- **Writes answers** = how fast the reply appears (tokens per second).
- **Reads your prompt** = how fast it takes in what you send (long documents, code, chat history), measured on a
32K-token prompt; a 4K prompt reads at 910-1,580 tokens/s. A 32K prompt takes about 15 seconds with Q2_0.
| Size | Writes answers | Reads your prompt |
| --- | ---: | ---: |
| **Q2_0** | 94 tokens/s | 2,650 tokens/s |
| **IQ2_XS** | 79 tokens/s | 2,090 tokens/s |
| **IQ3_XXS** | 62 tokens/s | 1,750 tokens/s |
| **IQ3_S** | 53 tokens/s | 1,620 tokens/s |
| **Coder** | 55 tokens/s | 2,180 tokens/s |
A card with more VRAM is faster, because more of the model fits on the GPU: an RTX 3090 (24 GB) should do roughly
100-140 tokens per second. All measurements, long-context numbers and estimates for other cards are in the
[details](docs/DETAILS.md#speed-measured).
</td><td>
Every PC is different: `START-HERE.bat --calibrate` measures a few engine settings on yours and keeps the fastest
(about 5-10 minutes; on the PC above it made the Coder 7% faster).
| Size | Writes answers | Reads your prompt |
| --- | ---: | ---: |
| **Q2_0** | 60 tokens/s | 1,160 tokens/s |
| **IQ2_XS** | 52 tokens/s | 1,110 tokens/s |
| **Coder** | 44 tokens/s | 1,420 tokens/s |
Measured Strata on your own PC? See [Community benchmark results](docs/COMMUNITY_BENCHMARKS.md)
for a report template and how to share your results in a pull request.
</td></tr>
</table>
**Two or three NVIDIA cards?** Just run `START-HERE.bat`: it lists your cards, says which ones Strata can use, and
asks whether to share the model across them (recommended when two can). An install made on one card asks once at
its next start. Or choose yourself: `START-HERE.bat --gpus 0,2` (both, remembered), `--gpus all`, or `--gpu 0` (one
card, this start only). Each card keeps the experts of its own layers, and prompts flow through the cards in a
pipeline: on an RTX 5080 + RTX 3090 prompts were read 18-20% faster than on the 5080 alone, decoding on par.
Every card must be an RTX 20 series or newer with 8 GB or more. See [docs/MULTI_GPU.md](docs/MULTI_GPU.md).
A card with more VRAM is faster: an RTX 3090 (24 GB) should write roughly 100-140 tokens per second. Long chats,
other cards: [speed of each model](docs/MODELS.md#how-fast-is-each-size), [community results](docs/COMMUNITY_BENCHMARKS.md).
## Which model should I pick?
<p align="center"><a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a><br>
<sub>Strata is free. If it runs well on your PC, a coffee keeps the work on it going.</sub></p>
**The size** (the same model, compressed more or less):
## What you need
| Model | RAM+VRAM Requirements | Speed | Quality |
| --- | ---: | --- | --- |
| **Q2_0** | 37.6 GB | fastest | good |
| **IQ2_XS** | 39.2 GB | fast | better (**recommended**) |
| **IQ3_XXS** | 47.0 GB | slower | great |
| **IQ3_S** | 54.8 GB | slowest | best: matches the full model on the published tests (original model only) |
| | |
| --- | --- |
| **Graphics card** | **NVIDIA** GeForce RTX 20, 30, 40 or 50 series, or **AMD** Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700 or RX 6800 / 6900 series - with **12 GB of VRAM or more** |
| **RAM** | 32 GB or more - how much decides [which model](#which-model-should-i-pick) fits; 64 GB runs every size |
| **Disk** | about 80 GB free, on an SSD if you can (the first start is much faster) |
| **System** | Windows 10 / 11 or Linux, and a current graphics driver from NVIDIA or AMD |
**Will it fit?** Shard 1 is the part of the model that gets loaded when it starts: its experts go into your **RAM**,
the rest onto your graphics card (the second shard, a 29 GB lookup table, stays on the SSD). So it fits when your
**RAM is at least shard 1 + about 10 GB** for Windows and your other programs. With 64 GB of RAM every size fits
(IQ3_S with little else open); with 48 GB, Q2_0 and IQ2_XS. A bigger graphics card makes it faster, but it doesn't
lower the RAM needed.
**The version:**
- **Qwen3.8-Flash-Next** - the original.
- **[Coder](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF)** - ISTA-DASLab's coding
version: half of the experts removed, keeping the ones that code, tool use and images need (91% of the full model's
SWE-bench Verified score, 99% of LiveCodeBench, by its authors). One size (IQ1_M: its experts stored like IQ3_S):
shard 1 is **29.6 GB**, so it fits a PC with **32 GB of RAM**, runs 262K context on 64 GB, and reads long prompts
the fastest of all. Weaker outside coding.
- **[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)** - a fine-tune by UkisAI
that thinks much shorter before answering, so you get the answer sooner, with about the same quality. Same speed per
token, and about the same RAM as the same size of the original (no IQ3_S). Its own license applies (see its page).
Not sure? Take **IQ2_XS** - or the **Coder** if you mainly write code, or have 32-48 GB of RAM. You can add another
one later with `SETUP.bat` (the same as `START-HERE.bat --setup`; on Linux `./setup.sh --setup`).
For **OrcaRouter's Flash-Next Uncensored IQ3_XXS**, see the [manual compatibility setup](docs/ORCA.md).
It needs an explicit packing conversion and is not an installer menu option.
An **AMD Radeon RX 7900 XT / XTX, RX 9070 / 9070 XT or Radeon AI PRO R9700 on Linux** works too (experimental; the
RX 7800 XT / 7700 XT and RX 9060 XT were validated by their owners):
`./setup.sh --backend hip`, chosen by itself on a PC with no NVIDIA card Strata can use. It installs ROCm without sudo
and compiles the engine (no images yet; several cards with `--gpus`). Details: [AMD HIP](docs/AMD_HIP.md).
Everything else is installed for you. Two or three cards can share the model ([multi-GPU](docs/MULTI_GPU.md)).
The full list: [docs/INSTALL.md](docs/INSTALL.md#what-you-need).
## Install
**You need:** an NVIDIA RTX 20, 30, 40 or 50 card with 12 GB of VRAM or more (RTX 20 since 0.1.27), enough RAM for the size you pick (above;
a big GPU makes up for less RAM - the [low-RAM mode](docs/DETAILS.md)),
~80 GB of free disk space (an SSD makes the first start much faster), and Windows 10/11 or Linux. The only thing you
install yourself is a current **NVIDIA driver** ([nvidia.com/drivers](https://www.nvidia.com/drivers) or the NVIDIA
App). Everything else - Python, the engine, the model - is set up for you.
### Let your AI set it up
**Windows**
Use an AI coding assistant (Claude Code, Cursor, Codex, GitHub Copilot, ...)? Paste this into it:
1. [Download this project](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) and unzip it (or `git clone` it).
2. Double-click **`START-HERE.bat`**.
3. Answer a few questions - or just press Enter each time for the recommended choice:
- **Which model and size?** The original or Swift 1.5, and Q2_0, IQ2_XS, IQ3_XXS or IQ3_S - see [above](#which-model-should-i-pick)
- **How much context?** How much text it can keep in mind at once (it suggests one for your card). 384K and
512K (experimental) extend the model past its trained 262K by rope scaling - the setup turns it on itself (yarn and a
covering factor; `--rope-scaling`/`--rope-scale` override) ([details](docs/DETAILS.md))
- **Images?** Whether it should also read pictures
- **Experimental speed projection?** Off unless you say yes - [read what it does](docs/DETAILS.md#experimental-speed-projection-experimental-off-by-default) first
```text
Set up Strata on this PC for me: https://github.com/Niko1221/Strata - follow docs/AI_SETUP.md in that repository.
```
Then it downloads everything (the model is ~70 GB, so the first time takes a while - you can stop and it picks up
where it left off) and **starts the model**. Your browser opens the Strata app at `http://127.0.0.1:8080`.
It checks your graphics card, RAM and disk, picks the model that fits, installs it, starts it and tells you how to
connect your apps. AI tools can also install, start and stop Strata themselves through its
[MCP server](docs/MCP_SERVER.md).
### Or do it yourself
[Download Strata](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) and unzip it (or `git clone` it).
**Windows:** double-click **`START-HERE.bat`**. **Linux:** run **`./setup.sh`** in the Strata folder.
The same steps for NVIDIA and AMD: the installer finds your card and sets up the right engine for it. It asks which
model, which size, how much context (how much text it keeps in mind) and whether it should read pictures - press
Enter each time for the recommended answer. Then it downloads the model (~70 GB; you can stop and it continues where
it left off) and starts it. Your browser opens the Strata app at `http://127.0.0.1:8080`.
> **While the model starts, your PC can be slow or stop responding for 1-3 minutes** (longest the first time): Strata
> loads 35-55 GB into your RAM and locks part of it for the graphics card. That's normal - wait, and don't close the
> window. The window tells you what it is doing.
**Next time**, just double-click `START-HERE.bat` again: it starts right away, nothing is downloaded twice. Close its
window to stop the model.
**Next time**, run `START-HERE.bat` (or `./setup.sh`) again: it starts right away, nothing is downloaded twice. Close
its window to stop the model. `UPDATE.bat` (`./update.sh`) updates Strata without starting it. Updating, Docker,
several cards, where the files go and every option:
[docs/INSTALL.md](docs/INSTALL.md).
**Updating:** download the new version and unzip it anywhere (or `git pull`), then run `START-HERE.bat` in it. The
model files are kept in a `Strata-data` folder next to your Strata folder, so a new copy finds them and sets itself up
the same way - nothing big is downloaded again.
## Which model should I pick?
**Linux:** run `./setup.sh` - same questions, same result.
The installer recommends one for your RAM. The same model comes in sizes that are compressed more or less: smaller
is faster, larger is a bit smarter.
**Docker (Linux):** the same idea, in a container.
| Your RAM | Take | Why |
| --- | --- | --- |
| **32 GB** | **Coder** | it fits 32 GB, and it is made for code (with a 24 GB card, Q2_0 and IQ2_XS run too) |
| **48 GB** | **IQ2_XS** (or Q2_0, the fastest) | the larger sizes do not fit |
| **64 GB** | **IQ2_XS** (recommended), or IQ3_XXS / IQ3_S | every size fits; IQ3_S is the best, and the slowest |
| **96 GB or more** | **IQ3_S**, or Unsloth's 4-bit (experimental) | room for the largest sizes with everything else open |
1. Host: Docker with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
and a driver **580 or newer** (CUDA 13.0).
2. Build (this compiles the engine into the image, so the container never compiles):
`docker build -t strata .`
`docker build -t strata --build-arg CUDA_ARCHITECTURES=89 .` builds for one card only (faster).
The default covers RTX 30 (86), RTX 40 (89), RTX 50 (120) and A-series (80); a card outside that
set needs a rebuild with its own arch. Add `--build-arg BUILD_VISION=0` to skip the image encoder.
3. Run (the first start downloads the ~70 GB model, then starts; later starts go straight to serving):
`docker run --rm --gpus all -p 8080:8080 --ulimit memlock=-1 -v strata-data:/data strata`
- **[Coder](docs/MODELS.md#coder)** - a coding version with half of the experts removed: 91% of the full model's
SWE-bench Verified score (by its authors), fits 32 GB of RAM. Weaker outside code, including Chinese and other
CJK text (#438): for those, take Q2_0, IQ2_XS or IQ3_S, which keep every expert.
- **[Swift 1.5](docs/MODELS.md#swift-15)** - a fine-tune that thinks much shorter before it answers, so you get the
answer sooner, at about the same quality.
- **[Unsloth UD-Q4_K_XL](docs/MODELS.md#unsloth-ud-q4_k_xl-experimental)** (experimental) - the closest to the full
model, but most of it is read from the SSD while it answers: 7-8.5 tokens/s on a 64 GB PC.
- **[OrcaRouter's Uncensored IQ3_XXS](docs/MODELS.md#orcarouter-uncensored-iq3_xxs)** - a manual setup, not in the
installer's menu.
The setup choices are env vars: `-e MODEL=IQ2_XS -e FAMILY=qwen -e CONTEXT=32768 -e VISION=no`
(or `MODEL=Q2_0|IQ3_XXS|IQ3_S`, `FAMILY=swift|coder`; the defaults above are the recommended ones).
`-e VISION=cpu` keeps the image encoder on the CPU. `-e KV=int8|q4_0|k8v4` picks the KV cache
precision; `k8v4` is INT8 K with 4-bit V and keeps its KV in VRAM from 64K up.
Only the model files, the prepared pack, the MTP layer and the install config live in the
`strata-data` volume; the engine is part of the image. Switching between models already on the
volume needs no setup pass: `-e MODEL=Q2_0 -e FAMILY=coder` picks that model's config. Add
`-e REINSTALL=1` only to change settings for a model already set up (context, vision, KV, host,
api_key, LOW_RAM), since those are recorded in its config.
Strata loads 32-62 GB into RAM. `--gpus all` on a host with two usable cards takes both: the
layer split is setup's recommended default ([docs/MULTI_GPU.md](docs/MULTI_GPU.md)), and a volume
set up for one card switches to the pair on its first start there. Pin one card with `-e GPU=0`,
or name them with `-e GPUS=0,2` and where the later card's layers start with `-e LAYER_SPLIT=18`.
A memory limit needs `-e LOW_RAM=on`, which maps the model's experts from the pack instead of
keeping them in RAM: setup.py measures the host's RAM, not the container's limit, so it cannot
see a cap. LOW_RAM runs on one card.
The server listens on `0.0.0.0:8080` by default; set `-e API_KEY=<secret>` before exposing the port
to a network. The image has a `HEALTHCHECK` on `/health`, so `docker ps` shows the container
healthy once the model is loaded, and `GET /v1/status` says what it is running.
Sizes, downloads and what fits where: [docs/MODELS.md](docs/MODELS.md). You can add another model later with
`SETUP.bat` (Linux: `./setup.sh --setup`).
## Using it
<p align="center"><img src="docs/media/runpagoda.png" width="900" alt="The Strata app's Monitor tab next to a coding agent"><br>
<sub>The Strata app's <b>Monitor</b> (left) while a coding agent writes the pagoda garden from the video (right)</sub></p>
- **In the browser:** `http://127.0.0.1:8080` - the Strata app (it opens by itself when the model starts): **Chat**, a
live **Monitor** of the model and your GPU/CPU/RAM, and **About** with the settings and addresses.
- **Chat in the terminal:** `.venv\Scripts\python chat.py`
- **Your apps and coding agents:** add it as an "OpenAI-compatible" provider with base URL
**`http://127.0.0.1:8080/v1`**, any API key and any model name. Apps that use Anthropic's API: `http://127.0.0.1:8080/v1/messages`.
- **Thinking:** the model thinks before it answers. Choose **off, low, medium or high** - in the chat page menu, with
`/think low` in `chat.py`, or with your app's "reasoning effort" setting. Off is fastest; high is best for hard questions.
- **Pictures:** in the chat page click **Picture**; in `chat.py` type `/image <path>`; in apps just attach them.
- **From your phone or another PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key <secret>`, then open the
address the server window prints; see the [details](docs/DETAILS.md#using-it).
- **Experimental speed projection (off by default):** an experimental control vector that setup can turn on; it
changes how the model answers - read [what it does](docs/DETAILS.md#experimental-speed-projection-experimental-off-by-default) first.
- **In the browser:** `http://127.0.0.1:8080` - **Chat**, a live **Monitor** of the model and your GPU/CPU/RAM, and
**About** with the settings and addresses.
- **Your apps and coding agents:** add 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` (Claude Code:
`ANTHROPIC_BASE_URL=http://127.0.0.1:8080`).
- **Thinking:** choose **off, low, medium or high** in the chat menu or your app's "reasoning effort". Off is
fastest; high is best for hard questions.
- **Pictures:** say yes to "Images?" in setup, then click **Picture** in the chat, or attach them in your app
(AMD cards: on Linux through the processor, not on Windows yet).
- **From your phone or another PC:** `START-HERE.bat --setup --host 0.0.0.0 --api-key <secret>` - always with a key.
- **Good to know:** it answers one request at a time. The first message of a chat is read in full (about 1 minute
per 30,000 tokens); follow-ups start in seconds.
**Good to know:** it answers one request at a time. The first message of a chat is read in full (about 1 minute per
30,000 tokens); after that it keeps the conversation and reads only what is new, so follow-ups start in seconds.
### Where things are stored
- **Your chats: only in your browser.** The Chat tab keeps the conversation, its settings and the API key you typed
in the browser's local storage (`strata.*` keys) - not on the server and not in the Strata folder. Pictures are not
kept, only their names. Another browser or a private window starts empty; clearing the site's data deletes them.
- **How the model starts:** `strata-<model>.json` in the Strata folder (context, GPUs, host, API key, ...), written
by setup; next to it `run-<model>.bat` / `.sh`, the log `strata-<model>.log` and, when you use "Use for other
apps too", `strata-<model>.shared-settings.json`.
- **The model files** (`models/`, `packs/`, `mtp/`, 70-120 GB): in **`Strata-data` next to the Strata folder**, or
wherever `--data-dir` put them.
- **Where that data folder is:** `%APPDATA%\Strata\settings.json` on Windows, `~/.config/strata/settings.json` on
Linux ([details](docs/DETAILS.md)).
More: [where your chats are stored](docs/INSTALL.md#where-things-are-stored), [the API](docs/DETAILS.md#using-it).
## Something went wrong?
**My PC froze, or got very slow, the first time Strata started.**
That's normal while it starts, most of all the first time. Strata loads 35-55 GB into your RAM, locks part of it for
the graphics card, and works out how much of the model fits on your GPU. The mouse can freeze for a few minutes. **Wait, and don't close the
window.** The next starts are much faster. Still frozen after 10 minutes? Restart the PC, close other programs
(browsers use a lot of RAM) and try again. If it keeps happening, pick a smaller size (Q2_0 or IQ2_XS).
- **My PC froze the first time Strata started.** Normal while it loads the model: wait, don't close the window.
Still frozen after 10 minutes? Restart the PC, close other programs and try again, or pick a smaller size.
- **It stopped while downloading or installing.** Run `START-HERE.bat` (or `./setup.sh`) again: it continues where
it stopped.
- **It's very slow and the disk light keeps blinking, or "the engine stopped unexpectedly".** Not enough free RAM:
close other programs (browsers use a lot), or pick a smaller size (Q2_0 or IQ2_XS).
- **It says port 8080 is already in use.** Strata is already running - look for its window.
**It stopped while downloading or installing.**
Run `START-HERE.bat` again. It continues where it stopped.
**It says the NVIDIA driver is too old.**
Update it (NVIDIA App or [nvidia.com/drivers](https://www.nvidia.com/drivers)), restart the PC, and run
`START-HERE.bat` again.
**It says port 8080 is already in use.**
Strata is already running. Look for its window.
**It's very slow and the disk light keeps blinking.**
Your PC is out of free RAM. Close other programs, or pick a smaller size (Q2_0 or IQ2_XS).
**An answer stopped with "the engine stopped unexpectedly".**
Usually not enough RAM (on Linux the system then stops the engine). Just send your message again: Strata starts the
engine by itself. If it keeps happening, close other programs or pick a smaller size.
**It says the prompt exceeds the context.**
The conversation is longer than the context you chose. Start a new chat, or run `SETUP.bat` and pick more
context.
**Still stuck?** Look in the [full troubleshooting table](docs/DETAILS.md#troubleshooting), or open an issue and
attach `strata-<model>.log` from the Strata folder.
More problems and their fixes: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). Still stuck? Open an
[issue](https://github.com/Niko1221/Strata/issues) and attach `strata-<model>.log` from the Strata folder.
## How does it work?
Models like this one normally run on servers with hundreds of gigabytes of graphics memory. Your graphics card has
12-24 GB. Strata makes it fit by **sharing the work across your whole PC** - the same idea as a kitchen, where the
things you use all the time stay on the counter and the rest waits in the pantry.
12-24 GB. Strata makes it fit by **sharing the work across your whole PC** - like a kitchen, where the things you use
all the time stay on the counter and the rest waits in the pantry.
<p align="center"><img src="docs/media/how-it-works.svg" width="860" alt="The model's 24,576 experts: the busiest on the graphics card, all of them in RAM, a lookup table on the SSD"></p>
- **The model is a team of 24,576 small specialists ("experts"),** and each word it writes needs only 10 of them.
So it doesn't have to have all of them on the graphics card at once.
- **Your graphics card** does the part of the work needed for every word, and keeps the few thousand experts that
are asked most often. It keeps learning which ones those are while you use it.
- **Your RAM** holds every expert. When a word needs one the card doesn't have, **your processor** works on it -
at the same time as the graphics card, so neither waits for the other.
- **Your SSD** holds a big lookup table; the model only reads a few small rows of it per word.
- **The model is a team of 24,576 small specialists ("experts"),** and each word needs only 10 of them.
- **Your graphics card** keeps the few thousand experts that are asked most often; **your RAM** holds all of them,
and **your processor** works on the rest at the same time. **Your SSD** holds a big lookup table.
<p align="center"><img src="docs/media/guess-and-check.svg" width="860" alt="A small helper guesses the next words; the big model checks them all at once and keeps the right ones"></p>
- **Guess, then check.** A small, fast helper built into the model guesses the next few words, and the big model
checks all the guesses in one go. It keeps the ones it agrees with and writes the next word itself - so one step
often produces several words. The helper only guesses - the big model decides every word - so you get the same
quality answer, 1.6-1.8x sooner.
- **Long texts are read in big pieces** (up to 8,192 tokens - pieces of words - at a time), which is why a long
document or code base is read at over 1,000 tokens per second.
- **Guess, then check:** a small helper guesses the next few words and the big model checks them all at once, so
you get the same answer, 1.6-1.8x sooner.
- **Long texts are read in big pieces** (up to 8,192 tokens at a time): over 1,000 tokens per second.
Want the full picture? The [details](docs/DETAILS.md#how-it-works) explain every part and its numbers, and the
[paper](docs/paper/Strata-Paper.pdf) tells the whole story, with the measurements behind it.
The longer explanation: [docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md). Every part and its numbers: [the
details](docs/DETAILS.md#how-it-works) and the [paper](docs/paper/Strata-Paper.pdf).
## Credits
## Credits and license
- Model: [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team; compressed versions by
[ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF);
[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF) by UkisAI. Their licenses apply
to the model files.
- Built with parts of [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp) (MIT). Ideas from
[Splash](https://github.com/incoai/splash), [ninfer](https://github.com/Neroued/ninfer) and
[HyperQwen](https://github.com/syv-ai/HyperQwen). More in the [details](docs/DETAILS.md#credits-and-licenses).
The model is [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team, compressed by
[ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF), UkisAI (Swift 1.5) and Unsloth;
Strata is built with parts of [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp). All credits:
[docs/HOW_IT_WORKS.md](docs/HOW_IT_WORKS.md#credits). Strata is open source under the [MIT License](LICENSE); a few
parts and every model carry their own licenses ([which ones](docs/HOW_IT_WORKS.md#license)).
## License
## Support Strata
Strata is open source under the [MIT License](LICENSE). A few parts carry their own licenses: `third_party/ggml`
(MIT, llama.cpp / ggml), the web app's font (SIL Open Font License 1.1) and the experimental speed projection's
vector in `data/experimental-speed-projection` (Qwen Community License 1.0, from the model's activations). The
models are not part of this repository; each model's own license applies to its files.
Strata is free and open source. If it is useful to you, you can support its development:
<p align="center"><a href="https://buymeacoffee.com/strataengine"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a></p>
+1 -1
View File
@@ -1,6 +1,6 @@
@echo off
rem Strata for Windows: the first run installs everything and starts the model; later runs just start it.
rem Needs only an NVIDIA driver. Python is installed for your user account if it is missing (no admin needed).
rem Needs only an NVIDIA or AMD graphics driver. Python is installed for your user account if it is missing (no admin needed).
setlocal
title Strata
cd /d "%~dp0"
+36
View File
@@ -0,0 +1,36 @@
@echo off
rem Update Strata without starting the model (#475): the newest code (git pull, when this folder is a git clone), then
rem what START-HERE.bat does before a start - the engine (a new one when this version needs it), the Python packages,
rem each installed model's settings and draft subset. The model files are not touched. Start the model later with
rem START-HERE.bat. Options are passed on to setup.py.
setlocal
title Strata - update
cd /d "%~dp0"
rem All of it in one block: cmd reads a .bat file while it runs it, and the git pull can change this very file.
(
if exist ".git" (
where git >nul 2>nul || (
echo This folder is a git clone, but git is not on PATH: install Git for Windows ^(winget install Git.Git^)
echo or run "git pull" here yourself, then run UPDATE.bat again.
pause
exit /b 1
)
echo Getting the newest Strata ^(git pull^) ...
git pull --ff-only || (
echo.
echo git pull did not succeed ^(the reason is above^): nothing was updated. Files you changed here can stop it:
echo "git status" lists them.
pause
exit /b 1
)
) else (
echo This copy of Strata was not made with git, so it cannot fetch new files itself. Download the newest one:
echo https://github.com/Niko1221/Strata/archive/refs/heads/main.zip
echo unzip it anywhere and run START-HERE.bat ^(or UPDATE.bat^) in it: it finds the model files in Strata-data
echo and sets itself up the same way - nothing big is downloaded again.
echo Checking this copy's engine and settings meanwhile ...
)
call "%~dp0START-HERE.bat" --update %*
if not errorlevel 1 pause
exit /b
)
@@ -0,0 +1,57 @@
# BF16 GEMV on Turing tensor cores (sm_75): measured negative
i7-8700K + RTX 2070 8 GB (sm_75, 36 SMs), engine 0.1.27 tree (`bf16_gemv.cu` is unchanged through 0.1.30),
qwen3.8-flash-next (~125.7B MoE, GGUF-verified) Q2_0 in production. Isolated benchmark only -
[`bench_tc_gemv.cu`](bench_tc_gemv.cu) touches nothing in the inference path. Question: the QSA prompt attention
already rides the Turing tensor cores (`mma.sync.m16n8k8`, the #270 path); can the decode GEMVs do the same?
## Setup
- Baseline: the production `bf16_gemv` warp kernel, transcribed verbatim
- TC variant: `mma.sync.aligned.m16n8k8.row.col.f32.f16.f16.f32` on BF16->FP16 (the sm_75 MMA has native f16 only).
BF16->FP16 conversion is exact (0 / 2,000,000 values inexact - bf16's 7-bit mantissa fits in fp16's 10 bits); the
A-fragment packing is the same one #270 validated (`a0` = row `gid`, k columns `2t..2t+1`; broadcast makes
`a0 == a1 == {x[2t], x[2t+1]}`)
- Three TC variants to cover the conversion overhead from every angle: W preconverted to fp16 once at load
(`pc`), W converted in-register every call (`in`), and a fused ks=1 form that writes the output directly and skips
the reduce kernel (`fused`)
- The k-split across blocks (`ks`) is swept over all values that divide n_in, and the best is reported - the TC
kernel gets its best occupancy
- Parity against an fp64 host reference; 2000 iterations x 3 reps, warmed CUDA events
## Results (best TC config per shape)
| Shape (decode, n_in = 2560, BF16 W) | Baseline us | Best TC us | Speedup | Config |
| --- | ---: | ---: | ---: | --- |
| ssm_alpha/beta [2560, 48] | 8.73 | 11.32 | 0.77x | pc, ks=16 |
| indexer.k_proj [2560, 128] | 7.14 | 12.09 | 0.59x | pc, ks=16 |
| indexer.q_proj / router [2560, 512] | 9.48 | 15.78 | 0.60x | pc, ks=4 |
| ple_value [2560, 2560] | 35.08 | 77.73 | 0.45x | pc, ks=40 |
- Numerics pass: TC max error vs fp64 reference 1.7e-6 .. 7.2e-6 (fp16-accumulation level), baseline
0.7e-6 .. 2.3e-6. The TC variant computes the same thing, it is just slower.
- The warp kernel is already bandwidth-bound at 276-374 GB/s effective W bandwidth (374 GB/s on the 13 MB
[2560, 2560] shape); the TC path reaches only 166-169 GB/s (4-byte lane loads, conversion instructions, in-tile
launch overhead).
- A single-token GEMV broadcasts one token across the whole m16 MMA tile, so 15/16 of the MMA throughput is thrown
away - but the baseline is not compute-bound, so the wasted throughput buys nothing. The k-split sweep and the
fused variant do not recover it.
- One-time W conversion cost is negligible (2 ms for 13 MB), so preconverting W for the TC path does not help.
## How to rerun
```
nvcc -arch=sm_75 -O3 -o bench_tc_gemv.exe bench_tc_gemv.cu
./bench_tc_gemv.exe
```
Prints the table above, per-shape latency/parity and the conversion exactness scan. On a newer card, adjust
`-arch`; the packed-A layout comment in the source documents the fragment mapping it relies on.
## Conclusion
**Keep the warp kernel. Tensor cores are not a promising direction for the decode GEMVs on sm_75**, and this was a
measured loss (1.3-2.2x slower), not a failed attempt to optimize. The Turing MMA stays worthwhile where the input
really has m16 rows of work: the QSA prompt-attention tiles, which is where #270 uses it. Someone looking to
re-evaluate on future hardware (a card with native bf16 or denser GEMV batching) can rerun this instead of
rebuilding it.
@@ -0,0 +1,305 @@
// bench_tc_gemv.cu - ISOLATED benchmark: BF16 GEMV baseline vs Tensor Core (mma.m16n8k8, BF16->FP16) on sm_75.
// Nothing in the engine changes. Shapes are the REAL Strata decode GEMVs (BF16 weights, n_in = n_embd = 2560):
// ssm_alpha / ssm_beta [2560, 48] project_bf16(split=true) -> bf16_gemv_split(TPR=32) -> warp kernel
// indexer.k_proj [2560, 128] project_bf16(split=false) -> bf16_gemv -> warp kernel
// indexer.q_proj / ffn_gate_inp / mlp.gate [2560, 512] warp / split(TPR=32) -> warp kernel
// ple_value [2560, 2560] warp
// Baseline = bf16_gemv_warp_kernel transcribed verbatim from src/kernels/cuda/bf16_gemv.cu (production).
// TC variant = same maths; A = x broadcast down m16 (single-token GEMV), k-chunk = 8;
// (a) W preconverted bf16->fp16 once, x converted in-kernel (timed) [tc_preconv]
// (b) fully inline: W read as bf16 and converted in-register [tc_inline]
// Numerics: fp64 host reference; max abs err for baseline and both TC variants; bf16->fp16 exactness scan.
#include <cuda_runtime.h>
#include <cuda_fp16.h>
#include <cstdio>
#include <cstdint>
#include <cstring>
#include <cstdlib>
#include <cmath>
#include <vector>
#include <string>
#include <functional>
#include <algorithm>
#define CHECK(e) do { cudaError_t err_ = (e); if (err_ != cudaSuccess) { \
fprintf(stderr, "CUDA %s:%d %s\n", __FILE__, __LINE__, cudaGetErrorString(err_)); exit(1); } } while (0)
__device__ __forceinline__ float bf16_f32(uint16_t v) { return __int_as_float((int) ((unsigned) v << 16)); }
static float bf16_f32_host(uint16_t v) { unsigned u = (unsigned) v << 16; float f; memcpy(&f, &u, 4); return f; }
static uint16_t f32_bf16_host(float f) {
unsigned u, r; memcpy(&u, &f, 4);
r = ((u >> 16) ^ (u >> 15)) & 1; u += 0x7fff + r;
return (uint16_t) (u >> 16);
}
// ============ baseline: production warp kernel (verbatim semantics from bf16_gemv.cu) ============
__global__ void gemv_warp(const uint16_t* __restrict__ x, const uint16_t* __restrict__ w,
float* __restrict__ y, long long n_in, long long n_out) {
const long long o = (long long) blockIdx.x * (blockDim.x >> 5) + (threadIdx.x >> 5);
if (o >= n_out) return;
const int lane = threadIdx.x & 31;
const uint16_t* row = w + o * n_in;
float acc = 0.0f;
for (long long i = lane; i < n_in; i += 32)
acc += bf16_f32(x[i]) * bf16_f32(row[i]);
for (int off = 16; off > 0; off >>= 1) acc += __shfl_down_sync(0xFFFFFFFFu, acc, off);
if (lane == 0) y[o] = acc;
}
// ============ TC: A(x) broadcast, k-split over blocks, partial D row 0 -> scratch ============
// A fragment (m16n8k8, .f16): lane l: a0 = A[m=l>>2][k=2(l&3)], a1 = A[m][k=2(l&3)+1],
// a2/a3 = same cols at row m+8. Broadcast => a2=a0, a3=a1.
// B fragment (.f16, col-major): lane l: b0 = B[k=2(l&3)][n=(l>>2)], b1 = B[k=2(l&3)+1][n].
// B[k][n] = W[n][k], so lane l loads W[n][2(l&3)], W[n][2(l&3)+1] - coalesced within the row.
// D fragment: c0/c1 = D[m=l>>2][cols 2(l&3), +1], c2/c3 = row m+8. All m rows identical -> row 0.
__global__ void tc_preconv(const uint16_t* __restrict__ x_b, const uint16_t* __restrict__ w_f,
float* __restrict__ partials, long long n_in, long long n_out, int ks) {
const int tile = blockIdx.x, ks_i = blockIdx.y, lane = threadIdx.x & 31;
const long long n_base = (long long) tile * 8;
const long long k_per = n_in / (long long) ks;
const long long k0 = (long long) ks_i * k_per;
const int t2 = 2 * (lane & 3);
float c0 = 0.f, c1 = 0.f, c2 = 0.f, c3 = 0.f;
for (long long kk0 = k0; kk0 < k0 + k_per; kk0 += 8) {
// A (2 x f16x2): a0 = {A[m][2t], A[m+8][2t]}, a1 = {A[m][2t+1], A[m+8][2t+1]} (broadcast -> dup halves)
unsigned xh = (unsigned) x_b[kk0 + t2] | ((unsigned) x_b[kk0 + t2 + 1] << 16);
// fp16 conversion in-register: bf16 -> f32 -> f16 (exact within fp16 range)
__half2 ha = __floats2half2_rn(bf16_f32((uint16_t) (xh & 0xFFFF)), bf16_f32((uint16_t) (xh >> 16)));
__half2 hb = __floats2half2_rn(bf16_f32((uint16_t) (xh & 0xFFFF)), bf16_f32((uint16_t) (xh >> 16)));
unsigned a0 = *reinterpret_cast<unsigned*>(&ha);
unsigned a1 = *reinterpret_cast<unsigned*>(&hb);
// B is already fp16 here: load as __half2 directly (bf16 path must NOT convert twice)
__half2 wbh = *reinterpret_cast<const __half2*>(&w_f[(n_base + (lane >> 2)) * n_in + kk0 + t2]);
unsigned b0 = *reinterpret_cast<unsigned*>(&wbh);
asm volatile("mma.sync.aligned.m16n8k8.row.col.f32.f16.f16.f32 {%0,%1,%2,%3}, {%4,%5}, {%6}, {%0,%1,%2,%3};\n"
: "+f"(c0), "+f"(c1), "+f"(c2), "+f"(c3)
: "r"(a0), "r"(a1), "r"(b0));
}
if (lane < 4) { float* p = partials + (size_t) (tile * ks + ks_i) * 8; p[t2] = c0; p[t2 + 1] = c1; }
}
// same, but W stays bf16 and converts in-register (fully fair per-call cost)
__global__ void tc_inline(const uint16_t* __restrict__ x_b, const uint16_t* __restrict__ w_b,
float* __restrict__ partials, long long n_in, long long n_out, int ks) {
const int tile = blockIdx.x, ks_i = blockIdx.y, lane = threadIdx.x & 31;
const long long n_base = (long long) tile * 8;
const long long k_per = n_in / (long long) ks;
const long long k0 = (long long) ks_i * k_per;
const int t2 = 2 * (lane & 3);
float c0 = 0.f, c1 = 0.f, c2 = 0.f, c3 = 0.f;
for (long long kk0 = k0; kk0 < k0 + k_per; kk0 += 8) {
unsigned xh = (unsigned) x_b[kk0 + t2] | ((unsigned) x_b[kk0 + t2 + 1] << 16);
__half2 ha = __floats2half2_rn(bf16_f32((uint16_t) (xh & 0xFFFF)), bf16_f32((uint16_t) (xh >> 16)));
__half2 hb = __floats2half2_rn(bf16_f32((uint16_t) (xh & 0xFFFF)), bf16_f32((uint16_t) (xh >> 16)));
unsigned a0 = *reinterpret_cast<unsigned*>(&ha);
unsigned a1 = *reinterpret_cast<unsigned*>(&hb);
const uint16_t* wbv = w_b + (n_base + (lane >> 2)) * n_in + kk0 + t2;
__half2 wbh = __floats2half2_rn(bf16_f32(wbv[0]), bf16_f32(wbv[1]));
unsigned b0 = *reinterpret_cast<unsigned*>(&wbh);
asm volatile("mma.sync.aligned.m16n8k8.row.col.f32.f16.f16.f32 {%0,%1,%2,%3}, {%4,%5}, {%6}, {%0,%1,%2,%3};\n"
: "+f"(c0), "+f"(c1), "+f"(c2), "+f"(c3)
: "r"(a0), "r"(a1), "r"(b0));
}
if (lane < 4) { float* p = partials + (size_t) (tile * ks + ks_i) * 8; p[t2] = c0; p[t2 + 1] = c1; }
}
__global__ void tc_reduce(const float* __restrict__ partials, float* __restrict__ y, long long n_out, int ks) {
const long long o = blockIdx.x * blockDim.x + threadIdx.x;
if (o >= n_out) return;
float s = 0.f;
for (int q = 0; q < ks; ++q) s += partials[(size_t) ((o >> 3) * ks + q) * 8 + (o & 7)];
y[o] = s;
}
// variant with the OTHER candidate A layout: a0 = {A[gid][2t], A[gid+8][2t]} (same k-col, two rows),
// a1 = same for col 2t+1. Broadcast => both halves of a0 hold x[2t], both halves of a1 hold x[2t+1].
__global__ void tc_dupcol(const uint16_t* __restrict__ x_b, const uint16_t* __restrict__ w_b,
float* __restrict__ partials, long long n_in, long long n_out, int ks) {
const int tile = blockIdx.x, ks_i = blockIdx.y, lane = threadIdx.x & 31;
const long long n_base = (long long) tile * 8;
const long long k_per = n_in / (long long) ks;
const long long k0 = (long long) ks_i * k_per;
const int t2 = 2 * (lane & 3);
float c0 = 0.f, c1 = 0.f, c2 = 0.f, c3 = 0.f;
for (long long kk0 = k0; kk0 < k0 + k_per; kk0 += 8) {
__half2 ha = __floats2half2_rn(bf16_f32(x_b[kk0 + t2]), bf16_f32(x_b[kk0 + t2]));
__half2 hb = __floats2half2_rn(bf16_f32(x_b[kk0 + t2 + 1]), bf16_f32(x_b[kk0 + t2 + 1]));
unsigned a0 = *reinterpret_cast<unsigned*>(&ha);
unsigned a1 = *reinterpret_cast<unsigned*>(&hb);
const uint16_t* wbv = w_b + (n_base + (lane >> 2)) * n_in + kk0 + t2;
__half2 wbh = __floats2half2_rn(bf16_f32(wbv[0]), bf16_f32(wbv[1]));
unsigned b0 = *reinterpret_cast<unsigned*>(&wbh);
asm volatile("mma.sync.aligned.m16n8k8.row.col.f32.f16.f16.f32 {%0,%1,%2,%3}, {%4,%5}, {%6}, {%0,%1,%2,%3};\n"
: "+f"(c0), "+f"(c1), "+f"(c2), "+f"(c3)
: "r"(a0), "r"(a1), "r"(b0));
}
if (lane < 4) { float* p = partials + (size_t) (tile * ks + ks_i) * 8; p[t2] = c0; p[t2 + 1] = c1; }
}
// FUSED, ks=1 only: with a single k-fragment the partial IS the answer -> write y directly, no reduce kernel,
// no scratch. Fully inline bf16 W conversion (fairest per-call TC cost).
__global__ void tc_fused(const uint16_t* __restrict__ x_b, const uint16_t* __restrict__ w_b,
float* __restrict__ y, long long n_in, long long n_out) {
const int tile = blockIdx.x, lane = threadIdx.x & 31;
const long long n_base = (long long) tile * 8;
const int t2 = 2 * (lane & 3);
float c0 = 0.f, c1 = 0.f, c2 = 0.f, c3 = 0.f;
for (long long kk0 = 0; kk0 < n_in; kk0 += 8) {
__half2 ha = __floats2half2_rn(bf16_f32(x_b[kk0 + t2]), bf16_f32(x_b[kk0 + t2 + 1]));
__half2 hb = ha; // broadcast: row gid+8 = row gid (single-token GEMV)
unsigned a0 = *reinterpret_cast<unsigned*>(&ha);
unsigned a1 = *reinterpret_cast<unsigned*>(&hb);
const uint16_t* wbv = w_b + (n_base + (lane >> 2)) * n_in + kk0 + t2;
__half2 wbh = __floats2half2_rn(bf16_f32(wbv[0]), bf16_f32(wbv[1]));
unsigned b0 = *reinterpret_cast<unsigned*>(&wbh);
asm volatile("mma.sync.aligned.m16n8k8.row.col.f32.f16.f16.f32 {%0,%1,%2,%3}, {%4,%5}, {%6}, {%0,%1,%2,%3};\n"
: "+f"(c0), "+f"(c1), "+f"(c2), "+f"(c3)
: "r"(a0), "r"(a1), "r"(b0));
}
if (lane < 4) { y[n_base + t2] = c0; y[n_base + t2 + 1] = c1; }
}
int main() {
CHECK(cudaSetDevice(0));
cudaDeviceProp prop; CHECK(cudaGetDeviceProperties(&prop, 0));
printf("device: %s, SMs %d, capability %d.%d\n", prop.name, prop.multiProcessorCount, prop.major, prop.minor);
struct Shape { const char* name; long long n_in, n_out; };
Shape shapes[] = {
{"ssm_alpha/beta [2560,48]", 2560, 48},
{"indexer.k_proj [2560,128]", 2560, 128},
{"q_proj/router [2560,512]", 2560, 512},
{"ple_value [2560,2560]", 2560, 2560},
};
auto rng = [](unsigned& s) { s = s * 1664525u + 1013904223u; return (float) ((s >> 8) & 0xFFFF) / 65535.0f - 0.5f; };
const int ITERS = 2000, WARM = 300;
printf("\n%-26s %13s | %s\n", "shape", "baseline us", "tc variants (pc = W preconverted, in = inline conversion, fused = no reduce), speedup vs baseline");
printf("%s\n", std::string(150, '-').c_str());
for (auto& sh : shapes) {
const long long n_in = sh.n_in, n_out = sh.n_out;
const size_t wn = (size_t) n_out * n_in;
std::vector<uint16_t> hw(wn), hx(n_in), hf(wn);
std::vector<float> hyb(n_out), hypc(n_out), hyin(n_out), hyfu(n_out);
std::vector<double> href(n_out);
unsigned seed = 991u + (unsigned) (n_out & 0xFFF);
for (size_t i = 0; i < wn; ++i) hw[i] = f32_bf16_host(rng(seed) * 0.3f);
for (long long i = 0; i < n_in; ++i) hx[i] = f32_bf16_host(rng(seed) * 3.0f);
for (long long o = 0; o < n_out; ++o) {
double s = 0.0;
for (long long i = 0; i < n_in; ++i) s += (double) bf16_f32_host(hx[i]) * (double) bf16_f32_host(hw[(size_t) o * n_in + i]);
href[o] = s;
}
// preconvert W to fp16 (bf16 -> fp16 is exact within fp16 range)
for (size_t i = 0; i < wn; ++i) { __half h = __float2half_rn(bf16_f32_host(hw[i])); memcpy(&hf[i], &h, 2); }
uint16_t *dx, *dw, *dwf; float *dy, *dyp;
CHECK(cudaMalloc(&dx, n_in * 2));
CHECK(cudaMalloc(&dw, wn * 2)); CHECK(cudaMalloc(&dwf, wn * 2));
CHECK(cudaMalloc(&dy, n_out * 4));
CHECK(cudaMalloc(&dyp, (size_t)(n_out / 8) * 65 * 8 * 4));
CHECK(cudaMemcpy(dx, hx.data(), n_in * 2, cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(dw, hw.data(), wn * 2, cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(dwf, hf.data(), wn * 2, cudaMemcpyHostToDevice));
// one-time W conversion cost (host->device, reported separately; the engine would fold this
// into the weight load exactly like Gemm::bf16 does for sm<80)
{
cudaEvent_t e0, e1; CHECK(cudaEventCreate(&e0)); CHECK(cudaEventCreate(&e1));
CHECK(cudaEventRecord(e0));
CHECK(cudaMemcpy(dwf, hf.data(), wn * 2, cudaMemcpyHostToDevice));
CHECK(cudaEventRecord(e1)); CHECK(cudaEventSynchronize(e1));
float ms = 0; CHECK(cudaEventElapsedTime(&ms, e0, e1));
printf("%-26s one-time W convert: %.2f ms (%.1f MB), tiles=%lld\n", sh.name, ms, wn * 2 / 1e6, n_out / 8);
}
int tiles = (int) (n_out / 8);
// valid ks must divide n_in so each k-chunk is a multiple of 8; sweep ALL of them, report the best
const int cands[11] = {1, 2, 4, 5, 8, 10, 16, 20, 32, 40, 64};
auto timeit = [&](const char* tag, const std::function<void()>& fn) -> double {
for (int i = 0; i < WARM; ++i) fn();
CHECK(cudaDeviceSynchronize());
double best = 1e30;
for (int rep = 0; rep < 3; ++rep) {
cudaEvent_t e0, e1; CHECK(cudaEventCreate(&e0)); CHECK(cudaEventCreate(&e1));
CHECK(cudaEventRecord(e0));
for (int i = 0; i < ITERS; ++i) fn();
CHECK(cudaEventRecord(e1)); CHECK(cudaEventSynchronize(e1));
float ms = 0; CHECK(cudaEventElapsedTime(&ms, e0, e1));
double us = ms * 1000.0 / ITERS;
if (us < best) best = us;
}
(void) tag; return best;
};
double t_base = timeit("base", [&]{ gemv_warp<<<(unsigned) ((n_out + 7) / 8), 256>>>(dx, dw, dy, n_in, n_out); });
gemv_warp<<<(unsigned) ((n_out + 7) / 8), 256>>>(dx, dw, dy, n_in, n_out);
CHECK(cudaMemcpy(hyb.data(), dy, n_out * 4, cudaMemcpyDeviceToHost));
// occupancy sweep over k-chunk count for the preconverted-W TC kernel
int best_ks = 1; double t_pc = 1e30;
for (int i = 0; i < 11; ++i) {
const int ksv = cands[i];
if (n_in % ksv != 0) continue;
dim3 g((unsigned) tiles, (unsigned) ksv);
double t = timeit("pcs", [&]{ tc_preconv<<<g, 32>>>(dx, dwf, dyp, n_in, n_out, ksv);
tc_reduce<<<(unsigned) ((n_out + 255) / 256), 256>>>(dyp, dy, n_out, ksv); });
if (t < t_pc) { t_pc = t; best_ks = ksv; }
}
dim3 gtc((unsigned) tiles, (unsigned) best_ks);
tc_preconv<<<gtc, 32>>>(dx, dwf, dyp, n_in, n_out, best_ks);
tc_reduce<<<(unsigned) ((n_out + 255) / 256), 256>>>(dyp, dy, n_out, best_ks);
CHECK(cudaMemcpy(hypc.data(), dy, n_out * 4, cudaMemcpyDeviceToHost));
double t_ti = timeit("tci", [&]{ tc_inline<<<gtc, 32>>>(dx, dw, dyp, n_in, n_out, best_ks);
tc_reduce<<<(unsigned) ((n_out + 255) / 256), 256>>>(dyp, dy, n_out, best_ks); });
tc_inline<<<gtc, 32>>>(dx, dw, dyp, n_in, n_out, best_ks);
tc_reduce<<<(unsigned) ((n_out + 255) / 256), 256>>>(dyp, dy, n_out, best_ks);
CHECK(cudaMemcpy(hyin.data(), dy, n_out * 4, cudaMemcpyDeviceToHost));
double t_fu = timeit("tcf", [&]{ tc_fused<<<(unsigned) tiles, 32>>>(dx, dw, dy, n_in, n_out); });
tc_fused<<<(unsigned) tiles, 32>>>(dx, dw, dy, n_in, n_out);
CHECK(cudaMemcpy(hyfu.data(), dy, n_out * 4, cudaMemcpyDeviceToHost));
double best_tc = t_pc; const char* best_var = "preconv";
if (t_ti < best_tc) { best_tc = t_ti; best_var = "inline"; }
if (t_fu < best_tc) { best_tc = t_fu; best_var = "fused"; }
double e_tc = 0, e_base = 0, sc = 0;
if (strcmp(best_var, "preconv") == 0) {
for (long long o = 0; o < n_out; ++o) { e_tc = std::max(e_tc, std::fabs((double) hypc[o] - href[o])); }
} else if (strcmp(best_var, "inline") == 0) {
for (long long o = 0; o < n_out; ++o) { e_tc = std::max(e_tc, std::fabs((double) hyin[o] - href[o])); }
} else {
for (long long o = 0; o < n_out; ++o) { e_tc = std::max(e_tc, std::fabs((double) hyfu[o] - href[o])); }
}
for (long long o = 0; o < n_out; ++o) {
e_base = std::max(e_base, std::fabs((double) hyb[o] - href[o]));
sc = std::max(sc, std::fabs(href[o]));
}
double gb_base = (double) (wn * 2) / t_base / 1e3;
double gb_tc = (double) (wn * 2) / best_tc / 1e3;
printf("%-26s %13.3f | pc %13.3f (ks=%d) | in %13.3f | fused %13.3f | BEST %s %7.3fx | maxerr tc %.2e base %.2e (scale %.1f, BW base %.0f tc %.0f GB/s)\n",
sh.name, t_base, t_pc, best_ks, t_ti, t_fu, best_var, t_base / best_tc,
e_tc, e_base, sc, gb_base, gb_tc);
CHECK(cudaFree(dx)); CHECK(cudaFree(dw)); CHECK(cudaFree(dwf)); CHECK(cudaFree(dy)); CHECK(cudaFree(dyp));
}
{ // bf16 -> fp16 exactness scan
unsigned seed = 777u; double worst = 0; int bad = 0, n = 2000000;
for (int i = 0; i < n; ++i) {
float f = (rng(seed) * 2.0f - 1.0f) * 100.0f;
uint16_t b = f32_bf16_host(f);
__half h = __float2half_rn(bf16_f32_host(b));
if ((double) __half2float(h) != (double) bf16_f32_host(b)) {
bad++; worst = std::max(worst, std::fabs((double) __half2float(h) - (double) bf16_f32_host(b)));
}
}
printf("\nbf16->fp16 conversion exactness: %d / %d inexact, worst %.3g\n", bad, n, worst);
}
printf("BENCH_DONE\n");
return 0;
}
+25 -9
View File
@@ -1,17 +1,25 @@
# Opt-in HIP configuration. Strata's CUDA-shaped kernels target wave32 RDNA3 / RDNA4 (64 KiB LDS per workgroup,
# the signed dot4 instruction). CMake/compiler discovery stays machine-independent; pass CMAKE_HIP_COMPILER when it
# Opt-in HIP configuration. Strata's CUDA-shaped kernels target wave32 RDNA2 / RDNA3 / RDNA4 (64 KiB LDS per
# workgroup, a signed dot4 instruction). CMake/compiler discovery stays machine-independent; pass CMAKE_HIP_COMPILER when it
# is not on PATH.
if(NOT DEFINED CMAKE_HIP_ARCHITECTURES OR CMAKE_HIP_ARCHITECTURES STREQUAL "")
set(CMAKE_HIP_ARCHITECTURES gfx1100 CACHE STRING "Strata HIP target architecture(s), e.g. gfx1100 or gfx1100;gfx1201")
endif()
# Validated on real cards: gfx1100 (RX 7900 XT / XTX) and gfx1201 (RX 9070 / 9070 XT, Radeon AI PRO R9700) by the
# maintainers; gfx1101 (RX 7800 XT, #254) and gfx1200 (RX 9060 XT, #256) by their owners. gfx1102 (RX 7600) has the
# same LDS limit and dot4 instruction and passed ctest (#192), but no model run has been reported yet.
# same LDS limit and dot4 instruction and passed ctest (#192), but no model run has been reported yet. RDNA2 gfx1030 (RX 6800 / 6900) has the
# same LDS limit and wave32 but an older dot4 instruction (v_dot4_i32_i8, hip_compat/intrinsics.hpp); a community
# report ran it (#311), the maintainers have not.
set(_strata_hip_validated gfx1100 gfx1201)
set(_strata_hip_community gfx1101 gfx1200)
set(_strata_hip_unvalidated gfx1102)
set(_strata_hip_unvalidated gfx1102 gfx1030)
# CMake hands HIP a ';' list, but a -DCMAKE_HIP_ARCHITECTURES typed by hand (or ROCm's own Windows tooling) may use
# spaces, which foreach(IN LISTS) would otherwise treat as one element.
string(REPLACE " " ";" _strata_hip_norm "${CMAKE_HIP_ARCHITECTURES}")
set(STRATA_HIP_ARCH_LIST "")
foreach(_arch IN LISTS CMAKE_HIP_ARCHITECTURES)
foreach(_arch IN LISTS _strata_hip_norm)
if(_arch STREQUAL "")
continue()
endif()
string(REGEX REPLACE ":.*$" "" _base "${_arch}") # gfx1100:xnack- -> gfx1100
if(_base IN_LIST _strata_hip_validated)
elseif(_base IN_LIST _strata_hip_community)
@@ -60,11 +68,19 @@ target_include_directories(strata_hip_runtime BEFORE INTERFACE
"${STRATA_HIP_COMPAT_INCLUDE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/include")
target_compile_definitions(strata_hip_runtime INTERFACE STRATA_USE_HIP=1 "STRATA_HIP_ARCHS=\"${STRATA_HIP_ARCHS}\"")
target_link_libraries(strata_hip_runtime INTERFACE hip::host)
foreach(_language IN ITEMS CXX HIP)
# The shim renames the CUDA runtime to HIP, force-included into every host and device source. On Windows the host
# compiler is ROCm's clang++ too (tools/hip/build_windows.bat: CMake refuses to mix cl.exe with Clang HIP), which takes
# -include like it does on Linux; an MSVC-style front end (cl / clang-cl) takes /FI instead. Forward slashes, so /FI
# does not read the path's backslashes as escapes.
file(TO_CMAKE_PATH "${STRATA_HIP_COMPAT_INCLUDE_DIR}/cuda_runtime.h" _strata_hip_force)
if(MSVC)
target_compile_options(strata_hip_runtime INTERFACE "$<$<COMPILE_LANGUAGE:CXX>:/FI${_strata_hip_force}>")
else()
target_compile_options(strata_hip_runtime INTERFACE
"$<$<COMPILE_LANGUAGE:${_language}>:-include>"
"$<$<COMPILE_LANGUAGE:${_language}>:${STRATA_HIP_COMPAT_INCLUDE_DIR}/cuda_runtime.h>")
endforeach()
"$<$<COMPILE_LANGUAGE:CXX>:-include>" "$<$<COMPILE_LANGUAGE:CXX>:${_strata_hip_force}>")
endif()
target_compile_options(strata_hip_runtime INTERFACE
"$<$<COMPILE_LANGUAGE:HIP>:-include>" "$<$<COMPILE_LANGUAGE:HIP>:${_strata_hip_force}>")
# CMake does not infer HIP from Strata's existing CUDA-shaped .cu suffixes.
file(GLOB_RECURSE _strata_hip_sources CONFIGURE_DEPENDS
Binary file not shown.
+196
View File
@@ -0,0 +1,196 @@
# Installing Strata for a user (instructions for AI agents)
This page is written for an AI coding assistant (Claude Code, Cursor, Codex, GitHub Copilot, ...) that a user asked to
set up Strata on their PC. Strata runs the Qwen3.8-Flash-Next model locally on one NVIDIA or AMD graphics card plus
system RAM, and serves an OpenAI- and Anthropic-compatible API on `http://127.0.0.1:8080`. The human-oriented
version of everything below is [INSTALL.md](INSTALL.md).
Work through the steps in order. Tell the user what you are doing in plain words; they may not be technical.
## 0. Ground rules
- **Never expose the server without an API key.** Keep the default `--host 127.0.0.1`. Only if the user asks for
access from other devices, use `--host 0.0.0.0` **together with** `--api-key <a long random secret>`, and give the
user the key. Never put a tunnel or port forward in front of a server without a key.
- Do not change the user's system beyond what setup does (setup installs Python for the user account if needed,
and everything else inside the Strata folder and its `Strata-data` folder). Ask before installing drivers.
- The model download is ~70 GB (up to 111 GB for Unsloth's 4-bit). Confirm the user is fine with that before you
start, especially on a metered connection.
- Setup is long-running (an hour or more on a slow connection). Run it in the background or with a long timeout and
poll its output; do not kill it because it is quiet for a while. It is resumable: running the same command again
continues where it stopped.
## 1. Check the PC
The quickest way is setup's own check, which prints the GPU(s), driver, RAM, CPU, free disk and whether this PC can run
Strata, and installs nothing except Python and the `.venv` it needs to run (step 2 gets the repository first):
```
Windows: START-HERE.bat --check
Linux: ./setup.sh --check
```
To check by hand:
| What | Windows (PowerShell) | Linux |
| --- | --- | --- |
| OS | `[Environment]::OSVersion` | `cat /etc/os-release` |
| NVIDIA GPU, VRAM, driver | `nvidia-smi --query-gpu=index,name,memory.total,driver_version --format=csv` | the same |
| AMD GPU | `Get-CimInstance Win32_VideoController \| Select-Object Name, AdapterRAM` (AdapterRAM is capped at 4 GB; trust the model name) | `lspci \| grep -i -E 'vga\|display'`; VRAM: `cat /sys/class/drm/card*/device/mem_info_vram_total` (bytes); `rocm-smi` only if ROCm is installed |
| RAM | `(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB` | `free -g` |
| Free disk | `Get-PSDrive -PSProvider FileSystem` | `df -h .` |
Requirements (details: [INSTALL.md](INSTALL.md#what-you-need)):
- **GPU:** NVIDIA RTX 20, 30, 40 or 50 series, or AMD Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT,
RX 9070 / 9070 XT, Radeon AI PRO R9700, RX 6800 / 6900 series; 12 GB of VRAM or more (an NVIDIA card with 8 GB runs,
slowly). GTX 10 series and older, and integrated GPUs, are not supported.
- **Driver:** NVIDIA 580 or newer. AMD on Linux: the kernel's amdgpu driver; on Windows: a current AMD Adrenalin
driver. If the driver is missing or too old, tell the user to update it (NVIDIA App / nvidia.com/drivers, or AMD
Software) and restart; do not install drivers yourself unless they ask.
- **RAM:** 32 GB or more (see step 3). **Disk:** ~80 GB free, ideally on an NVMe SSD. **CPU:** x86-64 with AVX2.
- **OS:** Windows 10/11 or Linux (Ubuntu 22.04/24.04 are fully automatic).
If the PC does not meet them, say which part is missing and stop.
## 2. Get Strata
```
git clone https://github.com/Niko1221/Strata.git
```
Without git: download https://github.com/Niko1221/Strata/archive/refs/heads/main.zip and unzip it. Pick a drive with
enough free space: the model files go to a `Strata-data` folder **next to** the Strata folder (or `--data-dir <path>`
on another drive).
## 3. Pick the model
By the PC's RAM (ask the user whether they mainly want it for code - then the Coder is a good pick at any RAM size):
| RAM | `--family` / `--model` | Notes |
| --- | --- | --- |
| 32 GB | `--family coder` (size IQ1_M) | for code; with a 24 GB card Q2_0 and IQ2_XS also run (setup picks the low-RAM mode) |
| 48 GB | `--family qwen --model IQ2_XS` | or `Q2_0` (fastest) |
| 64 GB | `--family qwen --model IQ2_XS` (recommended) | `IQ3_XXS` / `IQ3_S` are slower and a bit better |
| 96 GB+ | `--family qwen --model IQ3_S` | `--family unsloth --model UD-Q4_K_XL` is experimental: NVIDIA only, NVMe SSD, 7-8.5 tokens/s on 64 GB |
`--family swift` (Swift 1.5, a fine-tune that thinks shorter; sizes Q2_0, IQ2_XS, IQ3_XXS) is the alternative to
`qwen`. With `--yes` and no `--model`, setup picks the recommended size for the RAM itself. More: [MODELS.md](MODELS.md).
## 4. Run setup without questions
From the Strata folder (Windows: in `cmd`, or `cmd /c START-HERE.bat ...` from PowerShell):
```
Windows: START-HERE.bat --yes --family qwen --model IQ2_XS --no-start
Linux: ./setup.sh --yes --family qwen --model IQ2_XS --no-start
```
The flags (all of them: `START-HERE.bat --help`):
| Flag | Meaning |
| --- | --- |
| `--yes` | take the recommended answer to every question (no prompts) |
| `--family qwen\|swift\|coder\|unsloth` | the model version |
| `--model Q2_0\|IQ2_XS\|IQ3_XXS\|IQ3_S\|IQ1_M\|UD-Q4_K_XL` | the size (the Coder is IQ1_M, Unsloth UD-Q4_K_XL) |
| `--context N` | context in tokens; default by VRAM: 32768 under 14 GB, 65536 under 20 GB, else 131072 |
| `--vision yes\|no\|gpu\|cpu` | read pictures; `--yes` leaves images off. AMD cards: `cpu` |
| `--gpu N` / `--gpus 0,1` / `--gpus all` | one card, or several sharing the model (default: the card with the most VRAM) |
| `--backend cuda\|hip` | NVIDIA or AMD engine; chosen by itself on a PC with only one kind of card |
| `--data-dir PATH` | where the 70-120 GB of model files go |
| `--port N` | the server port (default 8080) |
| `--host H --api-key K` | listen beyond this PC; **only together with a key** |
| `--no-start` | install only, do not start the server |
| `--setup` | install another model or change settings of an installed one |
| `--check` | only check the PC |
`--no-start` is recommended for agents: the server runs in the foreground until its window is closed, which would
block your shell. Start it separately in step 6.
Notes:
- **Linux:** setup uses `sudo apt` (or dnf/pacman) to install Python with venv if it is missing, and on AMD may need
`build-essential` and `git`. You cannot type the user's password: if a `sudo` step is needed, ask the user to run it
(e.g. `sudo apt install python3-venv build-essential git`) and then rerun setup.
- **AMD on Linux:** setup installs ROCm into `.venv` (~10 GB, no sudo) unless a system ROCm 7 exists, and compiles the
engine for the card (10-20 minutes, once).
- **AMD on Windows (new in 0.1.34):** setup downloads the ready-made AMD engine (~550 MB, ROCm included) - nothing
is compiled and only the AMD driver is needed. Before the model download it runs `engine\strata-device.exe
--list-devices`; if that does not list the card, the driver is the problem (tell the user to update AMD Software).
One card per model and no images on Windows for now. It is new: ask the user to report how it runs (docs/AMD_HIP.md).
- **NVIDIA with no ready-made engine for the card:** setup offers to install build tools and compile (20-40 minutes);
`--yes` accepts.
## 5. While it downloads, tell the user
- It downloads about 70 GB from Hugging Face; how long depends on their connection (at 100 Mbit/s roughly 1.5-2
hours). It can be stopped and continues where it left off.
- Then it prepares the model for their PC (a few minutes) and, on AMD on Linux, compiles the engine.
- **When the model starts, the PC can be slow or stop responding for 1-3 minutes** (longest the first time): Strata
loads 35-55 GB into RAM and locks part of it for the graphics card. That is normal; they should not close the window.
- Nothing leaves their PC: the model runs locally.
## 6. Start the server
Setup prints the start script it wrote (`start script: run-<model>.bat`). The name is the family tag plus the size,
lower case: `run-iq2_xs`, `run-swift-iq2_xs`, `run-coder-iq1_m`, `run-unsloth-ud-q4_k_xl`.
```
Windows (PowerShell): Start-Process -FilePath ".\run-iq2_xs.bat" (opens its own window)
Linux: nohup ./run-iq2_xs.sh > strata-server.out 2>&1 &
```
Or `START-HERE.bat` / `./setup.sh` without flags, which starts the installed model (it asks which one when several are
installed; add `--yes` to take the first). The server opens the user's browser on `http://127.0.0.1:8080` when it is
ready. Closing its window (or stopping the process) stops the model.
## 7. Verify
The HTTP server answers once the model is loaded (30-90 s on later starts, a few minutes the first time). Poll:
```
curl http://127.0.0.1:8080/health
```
It returns `{"status": "ok", "model": ..., "max_context": ..., "images": ..., "api_key": ..., "loaded": true, ...}`.
Then:
```
curl http://127.0.0.1:8080/v1/models
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d "{\"model\": \"strata\", \"messages\": [{\"role\": \"user\", \"content\": \"Say hello in five words.\"}], \"max_tokens\": 64, \"reasoning_effort\": \"none\"}"
```
With an API key, add `-H "Authorization: Bearer <key>"`. The server window prints a `ready: http://127.0.0.1:8080/v1`
line when it is ready; the engine log is `strata-<model>.log` in the Strata folder.
## 8. Connect the user's apps
- **Browser:** `http://127.0.0.1:8080` - Chat, a live Monitor, and About (settings and addresses).
- **Any OpenAI-compatible app or agent:** base URL `http://127.0.0.1:8080/v1`, any API key (or the configured one),
any model name.
- **Anthropic-compatible apps:** `http://127.0.0.1:8080/v1/messages`.
- **Claude Code:** `ANTHROPIC_BASE_URL=http://127.0.0.1:8080`, `ANTHROPIC_MODEL` set to a Claude model name it knows
(Strata ignores the name), and any `ANTHROPIC_AUTH_TOKEN` (or the configured key).
- **Thinking level:** `"reasoning_effort": "none" | "low" | "medium" | "high"` (default high).
- Strata answers one request at a time. API details: [DETAILS.md](DETAILS.md#using-it).
## 9. When something fails
| Symptom | What to do |
| --- | --- |
| `the NVIDIA driver is too old` | The user updates the driver (NVIDIA App or nvidia.com/drivers) and restarts; rerun setup. |
| Download or install stopped | Rerun the same setup command; it continues. |
| `port 8080 is already in use` | Strata is already running (check `/health`), or use `--port 8081`. |
| Very slow, disk busy, or "the engine stopped unexpectedly" | Not enough free RAM: close programs, or set up a smaller size (`--setup --model Q2_0` or IQ2_XS). |
| `ExpertCache: cudaMalloc(...) failed: out of memory` with free VRAM (Windows) | The page file is off or tiny: set it to "System managed" and restart. |
| `prompt ... exceeds the context` | Rerun setup with `--setup --context <bigger>`. |
| No AMD GPU found (Linux) | The amdgpu driver lists no GPU; check the card and driver. Integrated GPUs are not supported. |
| Python or build tools could not be installed | Install what the message names (links are printed), then rerun. |
More: [TROUBLESHOOTING.md](TROUBLESHOOTING.md) and the [full table](DETAILS.md#troubleshooting). If it still fails,
collect `strata-<model>.log` and the setup output, and suggest an issue at https://github.com/Niko1221/Strata/issues.
## Alternative: the MCP server
Strata also has an MCP server, so an AI tool can check, install, start and stop Strata through tool calls
(`strata_status`, `strata_models`, `strata_install`, `strata_start`, `strata_stop`, `strata_logs`) instead of the
shell commands above. See [MCP_SERVER.md](MCP_SERVER.md).
+143 -12
View File
@@ -1,13 +1,15 @@
# Experimental AMD HIP backend (gfx1100, gfx1101, gfx1200, gfx1201)
# AMD Radeon: the HIP backend (gfx1100, gfx1101, gfx1200, gfx1201, gfx1030)
This is a Linux source build for the RX 7900 XT / XTX (RDNA3, gfx1100) and the
Strata runs on AMD Radeon cards through its HIP backend, the same engine as on NVIDIA compiled for AMD. This page
covers the build on Linux (on Windows a ready-made engine, see [Windows](#windows)) for the RX 7900 XT / XTX (RDNA3, gfx1100) and the
RX 9070 / 9070 XT / Radeon AI PRO R9700 (RDNA4, gfx1201; see [RDNA4](#rdna4-gfx1201)). The RX 7800 XT / 7700 XT
(gfx1101) and the RX 9060 XT (gfx1200) were validated by their owners (see [Community-validated
cards](#community-validated-cards)). It is opt-in; the NVIDIA installer and CUDA build remain the default. Other AMD
architectures, wave64, Windows HIP, and mixed AMD/NVIDIA execution are outside this contribution.
cards](#community-validated-cards)); the RX 6800 / 6900 series (RDNA2, gfx1030) builds and runs too, reported by a community machine and not yet validated by the maintainers (see [RDNA2](#rdna2-gfx1030)). Setup chooses it by itself on a PC with no NVIDIA card Strata can use (`--backend hip` on a PC with both); the
install steps for users are in [INSTALL.md](INSTALL.md#amd-cards). Other AMD architectures, wave64, and mixed
AMD/NVIDIA execution in one run are not supported.
The backend maps the CUDA-shaped runtime and BLAS calls to HIP/hipBLAS, uses
RDNA3/RDNA4's signed integer dot instruction for quantized kernels, and supplies
RDNA2/RDNA3/RDNA4's signed integer dot instruction for quantized kernels, and supplies
wave32 shuffle/packed-byte operations. CUDA-only QSA matrix instructions have
an ordered FP32 fallback. Prefill supports both dequantization plus hipBLAS GEMM and opt-in HIP ggml MMQ.
An optional, calibrated hipBLASLt path accelerates dense projections (per-architecture tables in `tools/hip`).
@@ -28,7 +30,9 @@ the kernel's amdgpu driver (no ROCm install needed):
- **ROCm:** a system ROCm 7 in `/opt/rocm` (or `$ROCM_PATH`) with hipcc and hipBLAS is used when present. Otherwise
(or when it is older than 7.0) ROCm is installed into `.venv` from AMD's TheRock wheels (~10 GB, no sudo), pinned
to the version this backend was tested with, from the card family's index: `gfx110X-dgpu` for gfx1100 / gfx1101,
`gfx120X-all` for gfx1200 / gfx1201 (`STRATA_ROCM_VERSION` / `STRATA_ROCM_INDEX` override them).
`gfx120X-all` for gfx1200 / gfx1201, `gfx103X-all` for gfx1030 (`STRATA_ROCM_VERSION` /
`STRATA_ROCM_INDEX` override them; the gfx1030 index is not checked to carry the pinned version: a system
ROCm 7 is the tested path there).
- **Engine:** compiled on your PC for the card's architecture (10-20 minutes, once; again after a `git pull` that
changes it, or when you pick a card of another architecture). This needs a C++ compiler and git
(`sudo apt install build-essential git`).
@@ -42,10 +46,69 @@ the kernel's amdgpu driver (no ROCm install needed):
architectures above; the engine is compiled for each of them (cards of two families, e.g. gfx1100 + gfx1201, need
a system ROCm 7: AMD's wheels hold one family). A split pays only when no single card holds the model's experts
(see RDNA4 below).
- **Limits for now:** no images, no calibration. The Monitor shows no GPU statistics.
- **Limits for now:** images only through the CPU encoder (`--vision cpu`, 0.1.32), no calibration. The Monitor
shows the card's load, VRAM, temperature and power from Linux sysfs (0.1.32).
The rest of setup is the same as on NVIDIA: the model download, the start script, the server.
## Windows
Since 0.1.34 an AMD card on Windows is set up like an NVIDIA one: download Strata, double-click `START-HERE.bat`.
On a PC with no NVIDIA card Strata can use, the AMD card is chosen by itself; with both, setup asks
(`START-HERE.bat --backend hip` picks AMD directly).
- **You need:** Windows 10 or 11 (64-bit), one of the cards above, and a current AMD driver ([AMD Software:
Adrenalin Edition](https://www.amd.com/en/support/download/drivers.html)). Nothing else: no ROCm or HIP SDK
install, no compiler, no admin rights.
- **Detection:** setup reads the display adapters Windows lists (their PCI ids; the VRAM size from the display
driver's registry entry). An integrated Radeon is listed as not supported.
- **Engine:** the ready-made `strata-windows-x64-hip.zip` from the release (built by `tools\hip\build_windows.bat`
for gfx1100, gfx1101, gfx1102, gfx1200, gfx1201 and gfx1030) goes into `engine\`. It carries the ROCm libraries the
engine loads (`engine\rocm\bin`: the HIP runtime, hipBLAS / rocBLAS / hipBLASLt with their kernels for these cards,
amd_comgr and the Microsoft C++ runtime; ROCm 10.2.0a20260930 from AMD's TheRock builds, licenses in
`engine\rocm\licenses`). The HIP runtime works through the AMD driver's own components, so the driver is the one
thing it needs from the PC.
- **The HIP runtime next to `strata.exe` (0.1.35, #468 #461):** `amdhip64_7.dll` and `amd_comgr.dll` are also put in
`engine\` (setup copies them there on every start). Windows looks in the program's folder before System32, where
some AMD drivers install their own `amdhip64_7.dll`; with that one, the bundled libraries crashed on the first
prompt (an access violation, or `hipErrorInvalidDeviceFunction`). The engine's log names the runtime it loaded
(`strata generate: HIP runtime ...`).
- **Before the ~60 GB model download** setup runs `engine\strata-device.exe --list-devices` (with `engine\rocm\bin` on
the PATH): if the HIP runtime does not see the card, setup stops there and points to the driver. It also gives the
card's HIP number: with an integrated Radeon that is device 1, not 0 (#325). From then on setup lists the AMD cards
as HIP numbers them, so `--gpu N` and the config's `"gpu"` are HIP numbers.
- **Differences from Windows-on-NVIDIA and Linux-on-AMD:** no images yet (the CPU image encoder is Linux-only for
now), one card per model (`--gpus` is Linux-only for now), no calibration.
- Two Windows-only engine details (#247, #325): hipBLAS can return success and still leave `hipErrorInvalidValue`
set after some BF16/FP16 GEMMs (seen on gfx1201); the engine clears that one stale error after a GEMM that
succeeded, on Windows only. `hipHostGetDevicePointer` returns the host pointer itself on Windows: kernels read
mapped memory through it correctly, but a device-to-device copy into it does not land, which is why
`tests/hip/handoff` times out there (the engine does not use that copy; `tests/hip/mapped_alias` reports it).
**What is validated (0.1.34):** #325's author ran the engine of this port on an RX 9070 XT (Windows 11, ROCm
10.2.0a20260930 in `.venv`, compiled on the PC): Coder IQ1_M at 32K, 29.2 tok/s decode, ~181 tok/s prefill,
correct answers; ctest 42 of 46. The maintainers have no Windows AMD card: the release zip was built on an NVIDIA PC,
and checked there on the Ryzen CPU's integrated Radeon (gfx1036, a test build of the same tree): with only the zip's
libraries on the PATH, `strata-device` lists the card and a hipBLAS BF16 GEMM matches the CPU; the HIP ctest passes
52 of 56, the 4 failures the same as on the RX 9070 XT (`hip_handoff`, and three tests that need a pack fixture).
The ready-made zip itself has not run a model on a discrete card yet - please report.
**Reporting a Windows AMD run** (an issue, or on #325): your card and driver version (AMD Software > System), then
```bat
engine\strata-device.exe --list-devices
engine\strata-device.exe --selftest
```
(from the Strata folder, after `set PATH=%CD%\engine\rocm\bin;%PATH%`), the end of `strata-<model>.log`, and the
speed lines the server window prints for a first answer.
**Building it yourself:** `tools\hip\build_windows.bat` (Visual Studio 2022 Build Tools with the C++ workload, Python,
git; no admin, no AMD GPU) installs ROCm from AMD's TheRock wheels into `.rocm-win`, builds, and packages
`dist\strata-windows-x64-hip.zip`; `START-HERE.bat --backend hip --prebuilt dist\` installs that one.
`tools\hip\build_windows.bat tests` also builds the HIP tests (`ctest` in `build-hip-win`, with
`.rocm-win\Lib\site-packages\_rocm_sdk_devel\bin` on the PATH). `STRATA_HIP_ARCHS` picks other architectures.
## Build
Requirements: a working ROCm driver/runtime, HIP development headers and
@@ -63,7 +126,7 @@ cmake --build build-hip --target strata -j2
`CMAKE_HIP_ARCHITECTURES` is `gfx1100`, `gfx1101`, `gfx1200`, `gfx1201`, or a list such as `"gfx1100;gfx1201"`
(one binary for both). gfx1102 (the same wave32, 64 KiB LDS and dot4 instruction) builds with a warning: it passed
ctest (#192) but no model run has been reported. At startup the engine and `strata-device` compare each GPU they use
ctest (#192) but no model run has been reported; so does gfx1030 (RDNA2: the older `v_dot4_i32_i8`, a community run in #311). At startup the engine and `strata-device` compare each GPU they use
(`gcnArchName` up to the `:` feature suffix) with the architectures the binary was compiled for, and require
wave32. A binary carried to another card stops with the card's name, its architecture and the build's list,
instead of failing later with "invalid device function".
@@ -120,7 +183,7 @@ The worker count above was used on a 16-core CPU; measure it for your CPU.
The 4K context is a smoke-test starting point, not a model limit. The expert cache
sizes itself automatically and leaves 1 GiB of VRAM headroom.
The installer supports this backend (see "Install with setup" above). The vision helper is NVIDIA-only for now.
The installer supports this backend (see "Install with setup" above). Images run through the CPU encoder for now (`--vision cpu`).
Setup installs one AMD card, or several with `--gpus` (the engine's layer split; see RDNA4 below).
## RDNA4 (gfx1201)
@@ -154,9 +217,11 @@ an RX 9070 XT 16 GB and a Radeon AI PRO R9700 32 GB (both gfx1201), a Ryzen 9 39
The engine's resident memory was about 26 GB in every run. Since 0.1.31 `__byte_perm` is one `v_perm_b32` and the
packed byte subtracts/compare work on four lanes at once (#262, ttio2tech): decode +15% on the R9700 (46.0 -> 53.0
tok/s on a 4K prompt, 52.0 -> 60.5 warm) and +5-7% on the 9070 XT, prompts unchanged, the same tokens.
- **hipBLASLt:** there is no gfx1201 table in `tools/hip`. A table calibrated on the R9700 at the engine's shapes
(hipBLASLt 1.4.1; 0.98-1.76x per GEMM over hipBLAS) changed the end-to-end prompt speed by 0-3%, within noise,
so none is shipped: on gfx1201 the plain hipBLAS path is already close.
- **hipBLASLt:** the validation above used hipBLASLt 1.4.1. A table calibrated on the R9700 at the engine's shapes
with that version (0.98-1.76x per GEMM over hipBLAS) changed the end-to-end prompt speed by 0-3%, within noise,
so none was shipped for it: there the plain hipBLAS path is already close. For hipBLASLt 1.5.0 (ROCm
10.2.0a nightly) `tools/hip/gfx1201-hipblaslt-100500.txt` is shipped (see "Tuning table" below); on one R9700 it
measured +3.9% prompt speed on 4,210-token prompts (1,590 vs 1,531 tok/s), a modest gain.
- **Both cards in one run (layer split, engine 0.1.30):** the config's `"backend": "hip", "gpu": [1, 0]` (R9700
first) runs through `serve/server.py` (setup writes it with `--gpus 1,0` since 0.1.31). Auto split put layers 0-27 on the R9700 and
28-47 on the 9070 XT. With every expert on the GPUs the split gives exactly the tokens of the R9700 alone (4K and
@@ -176,6 +241,26 @@ an RX 9070 XT 16 GB and a Radeon AI PRO R9700 32 GB (both gfx1201), a Ryzen 9 39
starts. `"split_skip_if_fits": true` in the config (0.1.31, opt-in) runs such a pair on the first card alone when it
holds every profiled expert: with the R9700 first, 4K prompts 1,776 tok/s (split: 1,244) and decode ~60 tok/s
(split: ~51), the tokens of the R9700 alone (docs/MULTI_GPU.md).
- **Speed switches (engine 0.1.32, measured on the R9700 / 9070 XT with the Coder IQ1_M pack):**
- the MoE router (`router_top10`) runs a HIP kernel without its serial FP64 sum and block barriers by default: the
same ids and weights bit for bit (`hip_router_fast` checks 65,536 rows), 39 -> 9-12 us per call, the same greedy
tokens (5 + 5 starts). Decode: 62.4 -> 70.0 tok/s on the R9700 and +4% on the 9070 XT in one A/B here (ROCm 10.2
nightly); a user's repeated A/B/A/B with setup's TheRock 7.10 wheels and an i5-12600K measured +1-4%, inside a
12-20% run-to-run spread (#432) - how much it gains depends on how much of decode the router is on that setup.
`STRATA_HIP_ROUTER_OLD=1` runs the portable kernel.
- `STRATA_HIP_WMMA=1` (opt-in, gfx12 only, int8 KV): the prompt path's QSA attention on RDNA4 matrix cores
(`v_wmma_f32_16x16x16_f16`, FP16 hi + lo halves like the CUDA tensor-core kernel; `hip_prompt_attn_wmma` bounds it
against the FP32 kernel and FP64). 7.2-7.5x the portable kernel; R9700 prompts 4K 1,784 -> 2,427 tok/s, 16K
1,797 -> 2,700 (a user with TheRock 7.10: +29-34%, #432). Not bitwise: greedy text differs from token ~50 on, as with the CUDA tensor-core attention. With it
the prompt path's expert ring is 96 slots (as STRATA_PREFILL_RING=96): with the default 384 the 9070 XT's 4K
prompts fell to 718 tok/s; with 96 they gain (1,017 -> 1,211; 16K 1,518 -> 2,032). PR #329
(bsorensen110) contributed an equivalent gfx12 WMMA kernel of the same speed (within 1%); this one also masks KV
pages that KV streaming has not made resident, as the decode kernel does.
- The prompt path's QSA top-k picks its kernel by the blocks a query actually has, not the cache's capacity (#337,
bsorensen110): the same ids, on by default with AMD (NVIDIA keeps its capacity rule: there the 64K prompts read
1-3% slower with it). `STRATA_SELECT_WMMA=1` (opt-in, gfx12) adds #337's
matrix-core block scorer; it selects slightly differently (254 of 256 queries the same) and gained +1.5% on 16K
prompts at a 262K context on the R9700.
- **Known:** rarely (about 1 start in 10) a HIP run's greedy output differs from another start's at some token, on
one card or two and on engine 0.1.29 as well; not yet explained.
- **Not validated:** images, long contexts beyond 16K, answer-quality benchmarks.
@@ -203,6 +288,38 @@ is shipped for them (make one with [Tuning table](#tuning-table) and compare the
Greedy output was the same across runs. For comparison, llama.cpp's HIP build measured 20 tok/s decode and
450 tok/s prompt on that card.
## RDNA2 (gfx1030)
The RX 6800 / 6800 XT / 6900 XT / 6950 XT run the same kernels: wave32 and 64 KiB of LDS per workgroup. The one
difference is the dot instruction: RDNA2 has no `v_dot4_i32_iu8` (gfx11 and newer), so
`dp4a` uses the plain signed `v_dot4_i32_i8` through `__builtin_amdgcn_sdot4`, which compiles to a single
`v_dot4c_i32_i8` (same signed x signed byte products, modulo 2^32). There is no WMMA; the QSA scorer takes the
same ordered FP32 fallback as gfx1100. CMake lists gfx1030 as unvalidated (the build warns) until a maintainer has
run it; the report below is from a community machine: an RX 6900 XT 16 GB (gfx1030), an i7-13700KF (8 P-cores and
8 E-cores, AVX2, no AVX-512), 63 GB RAM, NixOS, ROCm 7.2.3 from nixpkgs (clang 22, hipBLAS 3.2).
- **Build and tests** (engine 0.1.26): a complete HIP build for gfx1030, made by hand with cmake and ROCm's own
`clang++` (the nixpkgs ROCm is not an `/opt/rocm` tree, so setup's `build_engine_hip` was not exercised; the
binary was placed in `engine/` for setup to use). All 28 registered ctest tests pass on the card, including
`hip_device_selftest`. An engine 0.1.30 build of this branch configures, builds and runs clean on the same card;
the speeds below are measured on it.
- **End to end** (Swift 1.5 IQ3_XXS, `--context 131072` with `--kv-resident 32768 --adapt-every 1
--vram-reserve-mib 1024`, 200 greedy tokens): 38-42 tok/s decode with the default 15 CPU pool workers (one per
physical core except the host thread; 8 workers: 36; 24 = every logical core: 27), consistent even with the
131,072-token context full.
- **Prefill** (the same 15-worker configuration): 246 tok/s on a 2,000-token prompt, 330-339 tok/s at the auto
8,192-token chunk (7,997 and 15,967 tokens; time to first token 8.9 and 48.3 s), 133 tok/s on a 522-token
prompt - short prompts are fixed overhead (19-25 tok/s on 34 tokens). Decode after a 16K prefill holds at
45.5 tok/s.
- **16 GB card:** the expert cache holds 6,310 slots (10.2 GiB) at 16K context and 5,247 slots at 131,072, where
setup keeps the KV cache in VRAM because IQ3_XXS needs about 60 GB of RAM plus the cache to stream it. The
`--pcie-frac 0` and `--adapt-every 0` of the 7900 XTX configuration in
[AMD_HIP_PERFORMANCE.md](AMD_HIP_PERFORMANCE.md) cost 8.6 and 13.6 tok/s here (30 with the defaults): keep the
defaults on a 16 GB card.
- **hipBLASLt:** ROCm's hipBLASLt ships no gfx1030 kernels, so there is no table and the plain hipBLAS path runs.
- **Not validated:** gfx1031 / gfx1032 (the same `dp4a` path, no hardware report), setup's own build path and the
`gfx103X-all` wheels on gfx1030, images, answer-quality benchmarks.
## Tuning table
A hipBLASLt table holds solution ids that are valid only for one GPU architecture and one hipBLASLt version, so it
@@ -219,6 +336,20 @@ The file's second line names the architecture and version (`STRATA_HIPBLASLT_TUN
as `tools/hip/<arch>-hipblaslt-<version>.txt` for setup, or point `STRATA_HIPBLASLT_TUNING` at it. Compare the
prompt speed with and without it before keeping it.
Shipped tables:
- `gfx1100-hipblaslt-100100.txt`, `gfx1100-hipblaslt-100200.txt`: RX 7900 XTX.
- `gfx1201-hipblaslt-100500.txt`: Radeon AI PRO R9700 (gfx1201, 32 GB), calibrated with ROCm 10.2.0a20260914
(AMD's `gfx120X-all` nightly, hipBLASLt 1.5.0, library build `d3164197`). 16 dense GEMM geometries at T=4096 and
T=8192, 32 rows. setup uses it only when the installed hipBLASLt reports 1.5.0 (it is found in `/opt/rocm`
when that is a system ROCm 7 or newer). The version number is the only thing the engine can check, so another
1.5.0 build could number its solutions differently. `hip_prefill_hipblaslt_gemm` (with `STRATA_HIPBLASLT_TUNING`
set) is a smoke test: it refuses a table for another architecture or version and checks that one BF16 and one
F16 row exist and agree with hipBLASEx, which covers 2 of the 32 rows. It does not prove that a solution id is
valid: the engine falls back to hipBLASEx for an id the library rejects, and the test still passes. Run it with
`STRATA_HIPBLASLT_VERBOSE=1` and look for `fallbacks=0` in its summary line, and recalibrate with
`tune_hipblaslt` before using this table with a different 1.5.0 build.
## Original backend validation (PR #94)
The following is historical validation of the original backend, not a fresh
+161 -17
View File
@@ -1,11 +1,13 @@
# Strata - the details
The technical side of Strata: every measured number, the API, images, all settings and how the engine works.
New here? Start with the [README](../README.md) - it has everything you need to install and use it.
New here? Start with the [README](../README.md); installing step by step is in [INSTALL.md](INSTALL.md), the models in
[MODELS.md](MODELS.md), common problems in [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
> **On this page:** [Speed](#speed-measured) · [Other GPUs](#other-gpus-estimated) · [Which model?](#which-model) ·
> [Requirements](#before-you-start) · [Windows](#windows) · [Linux](#linux) · [API](#using-it) ·
> [MCP tools](#tools-from-mcp-servers) · [Images](#images-vision) ·
> [MCP tools](#tools-from-mcp-servers) · [MCP server](#manage-strata-from-your-ai-assistant-mcp-server) ·
> [Images](#images-vision) ·
> [Troubleshooting](#troubleshooting) · [How it works](#how-it-works)
---
@@ -17,6 +19,15 @@ RTX 5070 **12 GB**, Ryzen 5 7600 (6 cores), 64 GB DDR5-5200, Windows, engine 0.1
MTP speculative decoding on. "262K" is the model's full context window (a 259,943-token prompt). The IQ2_XS row was
measured with Swift 1.5's IQ2_XS, which runs at the original's speed.
**Engine 0.1.36 (#136), the same PC:** Q2_0's prompt experts run on fused int8 tensor-core kernels (RTX 30 and newer):
4K 1,294 -> 1,570, 32K 2,170 -> 2,653, 128K 2,123 -> 2,468 tokens/s (+16-22%), as close to an FP16 reference as the
previous kernels (closer at 32K: teacher-forced KL 0.009 vs 0.012). The decode path's block selection and greedy
argmax run on thread-block clusters (RTX 50, sm_90+; other cards keep the previous kernels; the same tokens): Q2_0 output at 4K 89 -> 93.5, at 128K
64.5 -> 76.4 tokens/s. `STRATA_PF_FUSED=0` keeps the previous prompt kernels (byte-identical answers to 0.1.35);
`STRATA_PF_FUSED=1` also runs the native IQ packs' fused kernels (opt-in: IQ2_XS prompts +12% at 4K, +3% at 32K, the
IQ3 packs about even); `STRATA_QSA_CLUSTER=0` / `STRATA_ARGMAX_MULTI=0` turn the decode kernels off. The tables
below are 0.1.26's.
### Prompt processing (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
@@ -50,14 +61,17 @@ accepted, so a different answer to the same prompt moves it by several percent.
[`bench/results/2026-09-28-speed-0114`](../bench/results/2026-09-28-speed-0114/README.md).
IQ3_XXS and IQ3_S at 262K are not measured: with their 43 / 50 GB of experts, a 260K-token context brings a 64 GB PC
to its memory limit. Use up to 128K with them on 64 GB (setup caps it). IQ3_S (engine 0.1.4 or newer) is only published
for the original model, not for Swift 1.5.
to its memory limit by setup's estimate (the experts + the context's KV cache + 24 GB), so setup recommends up to 128K
with them on 64 GB. A longer context you choose (`--context 262144`, or a pick in its list) is kept, with a note: users
ran IQ3_S at 256K on 64 GB with RAM to spare (#406). In the low-RAM mode the KV cache stays in VRAM and the context
does not count against RAM. IQ3_S (engine 0.1.4 or newer) is only published for the original model, not for Swift 1.5.
**KV streaming (engine 0.1.5):** at 64K and more, setup keeps the context's KV cache in RAM and only the part the
attention reads in VRAM (`--kv-resident 32768`), so more experts fit on the GPU. Q2_0 at 262K: 50.9 -> 62.6 tokens/s
(1,589 -> 3,872 experts in VRAM); at 128K about +6%. The attention reads exactly the same values (only where the KV lives changes); it
costs ~13.7 KB of RAM per context token (1.7 GB at 128K). Existing installs: run `START-HERE.bat --setup` once to turn
it on.
it on. Setup turns it on when the RAM has room for it; `--kv-streaming on|off` overrides that (on past the RAM test with a
note; never with `--kv k8v4` or under WSL, which cannot stream).
**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
@@ -76,14 +90,27 @@ slightly differently. How many tokens share an expert depends on the drafts in a
at temperature 0 can end in a different (equally good) answer when the drafting, the cache state or a resumed
conversation differ (issue #152). `STRATA_IQ_MT_MIN=1` (in the config's `env`) uses the multi-token kernels for
every group: the answer then no longer depends on the drafting. Measured on a Ryzen 7600 (AVX-512): IQ3_S decode
-1..-3%, the other models the same; the default stays the fastest rule.
-1..-3%, the other models the same; the default stays the fastest rule. Through the server, two more things carry
over from one request to the next (#410): the adaptive tier moves experts between RAM and VRAM (the GPU and the CPU
round an expert differently), and the prompt cache resumes a repeated prompt and reads only its tail through the
decode path. For byte-identical repeats add `--prompt-cache 0 --adapt-swaps 0 --pcie-frac 0` to the engine's args
as well (#410): the PCIe share of the missed experts (computed on the GPU instead of the CPU) still made the first
answer after a start differ from the next ones. Measured here (IQ3_XXS, a 3.6K-token prompt, 4 repeats): with all
three switches 1 answer of 4, without `--pcie-frac 0` 2 of 4 (the first one differs), with the defaults 2 of 4.
`--pcie-frac 0` costs decode speed (the missed experts all run on the CPU), so keep it for A/B runs.
**The draft layer's tokens (0.1.27, `--draft-vocab`):** the MTP draft layer can only propose tokens from a subset
of the vocabulary (`mtp/rt/draft_vocab.bin`). Since 0.1.27 the subset includes every Chinese, Japanese and Korean
token (106,299 ids), so answers in those languages are 15-38% faster (Q2_0, RTX 5070). Its head takes ~180 MiB of
VRAM, which the expert cache leaves free for it (0.1.28). `START-HERE.bat --setup --draft-vocab en` keeps the
English/code subset from before (40,525 ids, ~110 MiB less VRAM, English answers 1-2% faster; CJK answers get
almost no drafts). `tools/draft_vocab.py` builds and inspects subsets.
almost no drafts). `--draft-vocab cyrillic` takes the English/code subset plus the whole Cyrillic script (58,963
ids): the shipped subsets hold 142 of the vocabulary's 18,580 Cyrillic tokens, so Ukrainian or Russian answers got
1.4 tokens a round; with it 2.1, and 83 -> 109 tokens/s (RTX 5090, the NVFP4 fork), English unchanged.
`tools/draft_vocab.py` builds and inspects subsets. When the start stops with "the draft head does not fit" (a
12 GB card with a long context, #474), the engine says how much the head needs, how much VRAM is free and which
smaller subset fits, and the server's start error repeats it; setup suggests `--draft-vocab en` on cards under
14 GB (only a suggestion: nothing changes unless you pass it).
**Low-RAM mode (engine 0.1.26, chosen by setup):** normally all of a model's experts are copied into RAM (23-50 GB,
pinned) and the GPU holds a copy of the most-used ones. On a PC whose RAM cannot hold them beside the system (the
@@ -111,6 +138,12 @@ other ~18 GB), a 32 GB PC with a 12-16 GB GPU the Coder; IQ3_XXS on a 32 GB PC s
not hold do not fit, it says so and runs the plain mapped mode. The server log shows, per request, how many expert
reads went to the file (`resident RAM: ... blob reads from the file`: 0 in steady use).
- `--low-ram resident|mmap` forces one variant (also on a PC with enough RAM, e.g. to try it).
- Several GPUs (#364, #384): setup recommends one GPU in the low-RAM mode (the resident variant has no layer split
yet), and asks; `--gpus 0,1` (or answering 2) shares the model across them with the mapped variant
(`--mmap-experts`): the cards together hold more of the experts, and two users measured it 1.3-1.6x faster than
one card, but the OS file cache can fill the RAM to 0 free during long prompts. `--yes` keeps one GPU. A config
with `--resident-experts` started with `--gpus` switches to `--mmap-experts` with a note, and the engine runs that
pair as `--mmap-experts` with a warning instead of refusing it.
**Low-RAM mode without `experts.bin` (engine 0.1.31):** for the native packs (IQ2_XS, IQ3_XXS, IQ3_S, the Coder, Swift,
Q2_0 packed by `tools/iq_pack.py`; not the canonical Q2_0 pack setup makes for AVX-512 CPUs) the mapped mode no longer
@@ -125,7 +158,9 @@ engine fetches a layer's missing experts on 8 threads (`STRATA_FETCH_THREADS`) w
**A RAM budget (engine 0.1.31, `--resident-budget-gib N`):** the resident variant for a model whose experts do not all
fit: the N GiB of experts the GPU cache does not hold that the expert profile ranks hottest are copied into RAM at
start (locked; page-locked when the driver allows the whole budget), and the rest are read from the files through the
OS file cache. It implies `--mmap-experts` and leaves 4 GB of free RAM (a larger N is clamped, with a message). With
OS file cache. It implies `--mmap-experts` and leaves 4 GB of free RAM (a larger N is clamped to that less 256 MiB,
with a message; #403: a clamped budget no longer fails the safety check that follows, and a budget that cannot be
kept at all is a warning, with every expert read from the files). Setup sets N with `--resident-budget-gib N`. With
the GGUF read in place it also warms the next layer's likely experts: while the CPU works on a layer, a thread applies
the next layer's router to this layer's input and asks the OS for the pages of the predicted experts that neither the
GPU nor the RAM budget holds (only pages - the experts computed are the same; `STRATA_LOOKAHEAD=0` turns it off). This
@@ -136,7 +171,8 @@ RTX 5070, against ~3 tokens/s before these changes.
RAM copy, blobs and MB from the files, the time spent reading them; `routing prefetch`: how many of the file reads had
been warmed). The server log has the same per request (`expert tiers: GPU ... hits ...; RAM ... blobs, files ...
blobs ... MB read`), and `GET /metrics` lists `ram_blobs`, `file_blobs` and `file_mb` for each recent request (with
engine 0.1.31 or newer).
engine 0.1.31 or newer). It also lists each request's speculative drafts, `drafts_offered` and `drafts_accepted`
(`null` when the engine did not report them), and their sums since the server started in `totals` (#457).
Time to first token is prompt length / prompt speed: with Q2_0 about 4 s at 4K, 25 s at 32K, under 2 minutes at 128K
and 4.5 minutes at 262K (engine 0.1.13 made long prompts about twice as fast, below).
@@ -221,20 +257,24 @@ thought about for 1,524 tokens. Not a benchmark, but consistent with the claim.
START-HERE.bat --setup --family swift --model IQ2_XS
```
### Experimental: Unsloth's UD-Q4_K_XL (manual import)
### Experimental: Unsloth's UD-Q4_K_XL
A 4-bit quantization of the same model (111 GB, 72 GiB of experts). Not in setup: packing it and the server
configuration are in **[docs/UNSLOTH_Q4.md](UNSLOTH_Q4.md)**. On a 64 GB PC with a 12 GB RTX 5070 it writes 7-8.5
tokens/s, most experts read from the SSD; quality has not been measured against llama.cpp yet.
A 4-bit quantization of the same model (111 GB, 72 GiB of experts). Setup offers it from engine 0.1.32
(`--family unsloth --model UD-Q4_K_XL`: a RAM budget of your RAM less 24 GB, the rest read from the SSD); what it
does and the manual workflow are in **[docs/UNSLOTH_Q4.md](UNSLOTH_Q4.md)**. On a 64 GB PC with a 12 GB RTX 5070 it writes 7-8.5
tokens/s, most experts read from the SSD; it picks the same tokens as llama.cpp on the same file at 97.5-99% of
the positions of short greedy answers, 90-91% after a 16K prompt, differing mostly at near-ties
([measured](UNSLOTH_Q4.md#quality-against-llamacpp-on-the-same-file)).
## Before you start
You need **only an NVIDIA driver** (version 580 or newer; update it with the NVIDIA App or from
[nvidia.com/drivers](https://www.nvidia.com/drivers)). Everything else is installed for you the first time.
You need **only a graphics driver**: NVIDIA 580 or newer (update it with the NVIDIA App or from
[nvidia.com/drivers](https://www.nvidia.com/drivers)), or for AMD the one in [INSTALL.md](INSTALL.md#what-you-need).
Everything else is installed for you the first time.
| | |
| --- | --- |
| GPU | NVIDIA **RTX 20, 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070 and an RTX 3090; RTX 20 (Turing, since 0.1.27) was tested by a contributor on an RTX 2070. |
| GPU | NVIDIA **RTX 20, 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070 and an RTX 3090; RTX 20 (Turing, since 0.1.27) was tested by a contributor on an RTX 2070. Or AMD **Radeon RX 7900 XT / XTX, RX 7800 XT / 7700 XT, RX 9060 XT, RX 9070 / 9070 XT, Radeon AI PRO R9700, RX 6800 / 6900 series**: [AMD_HIP.md](AMD_HIP.md). |
| RAM | **64 GB** recommended (see the table above). |
| CPU | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. |
| Disk | ~70-80 GB free for the model, ~6 GB for the MTP layer (+1 GB with images). **Q2_0 on an AVX-512 CPU** also writes a one-time ~40 GB copy of its experts for the fast CPU kernel. An NVMe SSD is strongly recommended. |
@@ -373,6 +413,17 @@ the OS file cache, so loading again takes seconds while that RAM is not needed e
16 GB with Q2_0 in the low-RAM mode: unloading takes ~0.3 s, and a request to an unloaded model answered after
4.6 s (text) or 14.7 s (a picture, image encoder on the CPU).
**Keep what the expert cache learned across restarts (opt-in, engine 0.1.36, #477):** a start fills the GPU's expert
cache from the shipped profile, and the adaptive tier (`--adapt-every`) then moves in the experts your requests use.
With `"expert_profile_save": "expert-profile-learned.bin"` in `strata-<model>.json` the engine saves that as a
profile - the experts in VRAM first, then the routing it counted since the start, then the shipped order - on a
clean exit and every 10 minutes between requests (`"expert_profile_save_every": 5` for another interval, `0` for
exit only), written to a temporary file and renamed, so a crash never leaves half a file. The next start begins from
it instead of the config's `--expert-profile` when it is a profile of the same model (else from the config's, as
before). A relative path is in the Strata folder; one file per model, and a profile per project works the same way
(point the key at another file). The file is a fingerprint of what you used the model for: it stays on your PC.
Without the key nothing is counted or written. Setup rewrites the config when run again: add the key again then.
---
## Using it
@@ -421,6 +472,10 @@ print(r.choices[0].message.content)
part of the thinking the client sees and counts as output tokens. `"reasoning_budget_tokens": N` in
`strata-<model>.json` sets it for every request; a request's own value wins, and `0` means no budget. Off by default;
Anthropic's `"thinking": {"budget_tokens": N}` still only chooses the level, as above.
- **Anthropic requests that don't ask for thinking (opt-in, 0.1.32, #278).** By default a `/v1/messages` request
with no `"thinking"`, effort or budget thinks as the model's template does. `"anthropic_thinking": "on_request"` in
`strata-<model>.json` renders such a request without thinking - Anthropic's own rule, and what Claude Code's short
helper calls (a session title in a few dozen tokens) need; its real turns ask for thinking when it is on there.
- **Streaming.** With `"stream": true` everything arrives as it is made: the thinking, the answer, and tool calls
(the tool's name first, then its arguments piece by piece, like OpenAI and Anthropic do). While the model reads a
long prompt the stream sends keep-alives, so agents do not time out; the server window prints progress every
@@ -434,6 +489,9 @@ print(r.choices[0].message.content)
`max_tokens` would run past the context is refused too (400); agents that always ask for their full output cap
can instead get it shortened to the room left: add `"fit_max_tokens": true` to `strata-<model>.json` (or pass
`--fit-max-tokens` to `serve/server.py`). A prompt that leaves no room at all is still refused.
- **Model aliases** (0.1.32). `"aliases": ["qwen", "local-model"]` in `strata-<model>.json` lists the model under
those names too in `/v1/models` (each with its own `id`, and in the model's `aliases`), like llama-server's
`--alias`; a request naming one is answered under that name. Any other name is still served, as before.
- **From other devices on your network.** The server listens on your PC only (`127.0.0.1`) unless you say otherwise:
run setup with `START-HERE.bat --setup --host 0.0.0.0 --api-key some-long-secret` (or add `"host": "0.0.0.0"` and
`"api_key": "..."` to `strata-<model>.json`). The server window then prints this PC's addresses
@@ -444,7 +502,12 @@ print(r.choices[0].message.content)
- **From the internet.** Put a tunnel in front of it, for example [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/do-more-with-tunnels/trycloudflare/):
`cloudflared tunnel --url http://127.0.0.1:8080`. **Set a key first**, or anyone with the link can use your PC:
add `"api_key": "some-long-secret"` to `strata-<model>.json` (or set the `STRATA_API_KEY` environment variable);
clients then send it as their API key.
clients then send it as their API key. Streamed answers carry `X-Accel-Buffering: no`, so nginx-style proxies pass
each token on at once. The web app's settings and MCP tools only answer Strata's own page: when you open it through
a proxy or tunnel whose address differs, add that address, e.g. `"trusted_origins": ["https://strata.example.com"]`.
- **From web apps in a browser (CORS).** Off by default. `"cors_origins": ["https://chat.example.com"]` lets pages of
those origins call `/v1/*` from the browser (Open WebUI's direct connections, browser extensions); `["*"]` lets any
page do it - only sensible with an API key. It never opens `/settings`, `/unload` or the MCP tools.
**Conversation cache.** A request that continues a chat reads only the part after what the engine already holds: the
live session, or one of the checkpoints it keeps in RAM (up to 6, ~118 MB each, taken at the start of each new
@@ -591,6 +654,29 @@ unmeasured: all the runs above are text.
---
## Manage Strata from your AI assistant (MCP server)
`tools/strata_mcp.py` is an MCP server for Claude Code, Claude Desktop, Cursor, VS Code, Codex and other assistants.
Once it is added, you can ask your assistant "install Strata for this PC", "start Strata" or "is Strata running?".
In Claude Code, add it with:
```bash
claude mcp add strata -- python C:\Users\you\Strata\tools\strata_mcp.py
```
It has eight tools: status (the running model, what is installed, the hardware, a recommended size), the model
list, install, start, stop, logs, a speed test, and connection settings for other apps.
Install runs `setup.py` with `--yes` in the background. Before it downloads anything, it shows the plan and waits
for your OK. Start and stop work like the run scripts and the server's own unload. The MCP server only ends
processes it started itself. It uses only Python's standard library, so it works before `.venv` exists.
The config snippets for every client, the tool arguments and the safety rules are in
[docs/MCP_SERVER.md](MCP_SERVER.md). This is the opposite direction from
[Tools from MCP servers](#tools-from-mcp-servers) above, where the Strata model calls *your* MCP tools.
---
## Images (vision)
The model has a vision encoder: [`mmproj-Qwen3.8-Flash-Next-BF16.gguf`](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF)
@@ -606,6 +692,11 @@ helper (`strata-vision`, from llama.cpp's `mtmd` library) and adds it to your st
A picture becomes up to 1,024 tokens of the context (a 640x480 photo: 300). The same picture sent again, as chat apps
do on every turn, is encoded only once.
**A spare GPU for the encoder (0.1.33, #408):** with a card the engine doesn't use, add `"cuda_device": 2` (numbered
like `nvidia-smi`) to the `"vision"` section of `strata-<model>.json`: the encoder then runs on that card alone. Lower
`--vram-reserve-mib` in `"args"` to 700 as well, so the engine's cards keep that VRAM for the expert cache. The
encoder's card needs code in the ready-made encoder (RTX 20/30/40/50).
### Sending a picture
**Terminal chat:** type `/image <path to a picture>`, press Enter, then type your question.
@@ -770,3 +861,56 @@ Strata itself: [MIT](../LICENSE). The model files are not part of it; their lice
`serve/web/fonts/OFL.txt`). Its Monitor tab started from @code-martin's dashboard idea (PR #22).
- The experimental speed projection's vector (`data/experimental-speed-projection/`): Qwen Community License 1.0,
made from the model's activations (see its README).
### Start the text API without occupying the GPU
`serve/server.py --engine strata --config strata-<model>.json --lazy` (or `"lazy_load": true` in that
config) starts the lightweight HTTP API without spawning the native engine. The first generation request
loads it through the existing reload path, including `before_load` and `min_free_vram_mib`. Eager startup
remains the default. This option is text-only: a vision configuration with lazy startup is rejected explicitly.
`POST /v1/load` and `/v1/unload` are JSON control aliases for integrations, accepting `{}` or
`{"model":"<configured model>"}` and returning model status. They require the configured API key,
`application/json`, and no foreign browser Origin. They return **409** while a request is active or queued,
and **404** for an unknown model. Existing `/load` and `/unload` behavior is preserved. `/api/health` aliases
`/health`; `/v1/status` exposes `loaded` and `auto_load`. The unloaded model remains discoverable.
Unloading and shutdown close the native engine's stdin after sending `QUIT`, allowing Windows' detached
stdin reader to see EOF. Cleanup waits for process exit before releasing handles; if forced shutdown still
times out, the server keeps ownership and reports an error rather than claiming the model was unloaded.
### JSON response formats
`POST /v1/chat/completions` accepts `response_format: {"type":"json_object"}` or
`{"type":"json_schema","json_schema":{"name":"answer","strict":true,"schema":{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"],"additionalProperties":false}}}`.
The schema must describe an object at its root. Local `#` references work; remote references are refused.
`json_schema` is checked with the Python package `jsonschema` when it is installed (`python -m pip install
"jsonschema>=4.23,<5"`; setup does not add it); without it the answer is only checked to be one JSON object, and the
server says so once.
This is **schema prompting followed by server validation**, not grammar-constrained decoding. One generation
is made per request, with no hidden retry. Successful responses contain a validated JSON object. Malformed JSON,
duplicate keys, non-finite numbers, schema violations and incomplete generations return **502** with
`error.code: structured_output_failed`; invalid request schemas return **400**. JSON formats combined with
tools/MCP are refused explicitly. Without `response_format`, ordinary text and tool behavior stays the same.
Structured SSE buffers the answer while sending keep-alive comments. It emits content only after validation,
then usage/timings and `[DONE]`; failures emit an SSE error and `[DONE]` without invalid content deltas.
`/v1/status.structured_output` advertises the formats, validation method and buffered streaming behavior.
### API request monitor
Off by default, since it keeps prompts and answers in memory: turn it on with `"api_monitor": true` in
`strata-<model>.json` (or `serve/server.py --api-monitor`); otherwise nothing is recorded and the two endpoints below
answer 404.
Open `/api-monitor` to inspect API traffic without opening a chat. It shows the model state, safe
load/unload controls, active/queued requests, original request bodies, output, separate reasoning and
non-stream response bodies. Total wall-clock includes FIFO waits and automatic loading; load, queue,
first-token, prompt/output tokens and engine decode timing are shown separately.
`GET /api/requests` returns compact summaries; `GET /api/requests?id=<id>` returns one retained request.
Both use the existing API-key check. The monitor retains the newest **100 requests in memory** until restart,
with **262,144 characters per input/output/reasoning/response field** and visible truncation flags. The actual API
responses are unaffected. Headers are not recorded, and the monitor key is kept in this tab's session storage.
Treat request history as sensitive input/output when exposing Strata on a network: set an API key as above.
The page uses relative URLs and works through the existing host binding or a reverse proxy.
+77
View File
@@ -0,0 +1,77 @@
# How does Strata work?
Models like Qwen3.8-Flash-Next normally run on servers with hundreds of gigabytes of graphics memory. Your graphics
card has 12-24 GB. Strata makes it fit by **sharing the work across your whole PC** - the same idea as a kitchen, where
the things you use all the time stay on the counter and the rest waits in the pantry. Back to the
[README](../README.md#how-does-it-work).
## Who does what
<p align="center"><img src="media/how-it-works.svg" width="860" alt="The model's 24,576 experts: the busiest on the graphics card, all of them in RAM, a lookup table on the SSD"></p>
- **The model is a team of 24,576 small specialists ("experts"),** and each word it writes needs only 10 of them.
So it doesn't have to have all of them on the graphics card at once.
- **Your graphics card** does the part of the work needed for every word, and keeps the few thousand experts that
are asked most often. It keeps learning which ones those are while you use it.
- **Your RAM** holds every expert. When a word needs one the card doesn't have, **your processor** works on it -
at the same time as the graphics card, so neither waits for the other.
- **Your SSD** holds a big lookup table; the model only reads a few small rows of it per word.
The same in technical terms:
- **GPU (VRAM):** attention and DeltaNet mixers, the gated-residual weights, routers, shared experts, output head, the
MTP draft layer, the KV cache (from 64K: only its most-read part, the rest streams from RAM), and an **expert
cache** that fills the rest of VRAM with the most-used experts (it adapts to the conversation while you chat).
More VRAM matters more than a faster GPU: every extra GB holds ~700 more experts, and every expert on the GPU is one
the CPU does not have to compute.
- **RAM:** all 24,576 experts, pinned. The CPU computes the experts that are not on the GPU **in place**, at the same
time as the GPU works on the cached ones (AVX-512 / AVX2 kernels, ggml's for the i-quants). On a PC with little RAM
and a big card, the [low-RAM mode](MODELS.md#a-big-graphics-card-and-little-ram) keeps in RAM only what the GPU does
not hold.
- **SSD:** the 28.8 GB n-gram table, read a few rows per token through the OS cache.
This works the same on NVIDIA (CUDA) and AMD (HIP) cards: the same engine, compiled for each.
## Guess, then check
<p align="center"><img src="media/guess-and-check.svg" width="860" alt="A small helper guesses the next words; the big model checks them all at once and keeps the right ones"></p>
- **Guess, then check.** A small, fast helper built into the model guesses the next few words, and the big model
checks all the guesses in one go. It keeps the ones it agrees with and writes the next word itself - so one step
often produces several words. The helper only guesses - the big model decides every word - so you get the same
quality answer, 1.6-1.8x sooner.
- In technical terms: the model's own MTP layer drafts up to 3 tokens; one pass over all 48 layers checks them, 2.4-3.2
tokens per pass on average. When the reply repeats the context (code edits, quoted text), **prompt lookup** 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.
## Reading long texts
**Long texts are read in big pieces** (up to 8,192 tokens - pieces of words - at a time), which is why a long
document or code base is read at over 1,000 tokens per second. The experts of the next layer stream to the GPU over
PCIe while the current layer's attention runs. After the first message, Strata keeps the conversation and reads only
what is new, so follow-ups start in seconds.
## More
The [details](DETAILS.md#how-it-works) explain every part and its numbers, and the
[paper](paper/Strata-Paper.pdf) tells the whole story, with the measurements behind it.
## Credits
- Model: [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next) by the Qwen team; compressed versions by
[ISTA-DASLab](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF);
[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF) by UkisAI; the experimental
[UD-Q4_K_XL](https://huggingface.co/unsloth/Qwen3.8-Flash-Next-GGUF) by Unsloth (its support follows
[eddoursul/Strata](https://github.com/eddoursul/Strata)). Their licenses apply to the model files.
- Built with parts of [llama.cpp / ggml](https://github.com/ggml-org/llama.cpp) (MIT). Ideas from
[Splash](https://github.com/incoai/splash), [ninfer](https://github.com/Neroued/ninfer) and
[HyperQwen](https://github.com/syv-ai/HyperQwen). More in the [details](DETAILS.md#credits-and-licenses).
- The AMD (HIP) backend: [AMD_HIP.md](AMD_HIP.md) names the contributors and the machines it was validated on.
## License
Strata is open source under the [MIT License](../LICENSE). A few parts carry their own licenses: `third_party/ggml`
(MIT, llama.cpp / ggml), the web app's font (SIL Open Font License 1.1) and the experimental speed projection's
vector in `data/experimental-speed-projection` (Qwen Community License 1.0, from the model's activations). The
models are not part of this repository; each model's own license applies to its files.
+196
View File
@@ -0,0 +1,196 @@
# Installing Strata
Everything about installing, starting, updating and configuring Strata, on Windows and Linux, with an NVIDIA or an
AMD graphics card. The short version is in the [README](../README.md#install); an AI coding assistant can do all of
this for you with [AI_SETUP.md](AI_SETUP.md).
> **On this page:** [What you need](#what-you-need) · [Windows](#windows) · [Linux](#linux) ·
> [AMD cards](#amd-cards) · [Several cards](#two-or-three-cards) · [Docker](#docker-linux) · [Updating](#updating) ·
> [Where things are stored](#where-things-are-stored) · [Setup's questions](#setups-questions) ·
> [Tuning](#tuning-for-your-pc) · [All options](#options-without-questions)
## What you need
| | |
| --- | --- |
| **GPU** | **NVIDIA** RTX 20, 30, 40 or 50 series, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070 and an RTX 3090; RTX 20 (Turing, since 0.1.27) was tested by a contributor on an RTX 2070. **AMD** Radeon RX 7900 XT / XTX, RX 9070 / 9070 XT and Radeon AI PRO R9700 (validated), RX 7800 XT / 7700 XT and RX 9060 XT (validated by their owners), RX 6800 / 6900 series (community-reported), with 12 GB of VRAM or more. See [AMD cards](#amd-cards). |
| **RAM** | Enough for the size you pick ([which model](MODELS.md#pick-by-ram)); **64 GB** runs every size. A big GPU makes up for less RAM - the [low-RAM mode](MODELS.md#a-big-graphics-card-and-little-ram). |
| **CPU** | x86-64 with AVX2 (any Intel/AMD desktop CPU from the last ~8 years). AVX-512 (Ryzen 7000/9000) is a bit faster. |
| **Disk** | ~70-80 GB free for the model, ~6 GB for the MTP layer (+1 GB with images). **Q2_0 on an AVX-512 CPU** also writes a one-time ~40 GB copy of its experts for the fast CPU kernel. On Linux with an AMD card, ROCm takes ~10 GB more when setup installs it. An NVMe SSD is strongly recommended: it makes the first start much faster. |
| **OS** | Windows 10/11, or Linux (Ubuntu 22.04/24.04 get everything installed automatically). |
| **Driver** | **NVIDIA:** a current driver, version 580 or newer ([nvidia.com/drivers](https://www.nvidia.com/drivers) or the NVIDIA App). **AMD:** on Linux the kernel's amdgpu driver (no ROCm install needed); on Windows a current AMD Software: Adrenalin Edition driver ([amd.com/support](https://www.amd.com/en/support)). |
The driver is the only thing you install yourself. Everything else - Python, the engine, the model - is set up for
you the first time: Python 3.12 if you have none (for your user account, no admin), a private Python environment in
`.venv/`, the Strata engine, the model and the MTP draft layer. On NVIDIA it uses the ready-made engine for RTX
20/30/40/50 and NVIDIA's CUDA libraries from pip (~0.4 GB); if no ready-made engine fits your PC, it offers to install
the build tools (Visual Studio Build Tools + CUDA Toolkit on Windows, `build-essential` + CUDA on Ubuntu) and compiles
the engine for your GPU (asks first; 20-40 minutes once). More: [details](DETAILS.md#before-you-start).
## Windows
1. [Download this project](https://github.com/Niko1221/Strata/archive/refs/heads/main.zip) and unzip it (or
`git clone` it).
2. Double-click **`START-HERE.bat`**.
3. Answer a few questions - or just press Enter each time for the recommended choice ([the questions](#setups-questions)).
Then it downloads everything (the model is ~70 GB, so the first time takes a while - you can stop and it picks up
where it left off) and **starts the model**. Your browser opens the Strata app at `http://127.0.0.1:8080`.
> **While the model starts, your PC can be slow or stop responding for 1-3 minutes** (longest the first time): Strata
> loads 35-55 GB into your RAM and locks part of it for the graphics card. That's normal - wait, and don't close the
> window. The window tells you what it is doing.
**Next time**, just double-click `START-HERE.bat` again: it starts right away (30-90 s to load the model), nothing is
downloaded twice. Close its window to stop the model. `SETUP.bat` (the same as `START-HERE.bat --setup`) installs
another model or changes the settings. Starting Strata from Task Scheduler at logon needs two task settings, or the
start is 24x slower: [Running it at startup](DETAILS.md#running-it-at-startup-task-scheduler).
## Linux
```bash
./setup.sh
```
The same questions, the same automatic install (it uses `sudo apt` for Python and, only if it has to compile, for
the build tools), and the same start: `http://127.0.0.1:8080`. Later runs of `./setup.sh` (or `./run-<model>.sh`)
start the model directly; `./setup.sh --setup` installs another model or changes the settings. Other distributions,
WSL and compiling: [details](DETAILS.md#linux).
## AMD cards
The steps are the same as with NVIDIA: `START-HERE.bat` on Windows, `./setup.sh` on Linux. Setup finds the Radeon
card and chooses the AMD (HIP) engine by itself on a PC with no NVIDIA card Strata can use; `--backend hip` chooses
it on a PC that has both. Supported cards: RX 7900 XT / XTX (gfx1100), RX 7800 XT / 7700 XT (gfx1101), RX 9060 XT
(gfx1200), RX 9070 / 9070 XT and Radeon AI PRO R9700 (gfx1201), and the RX 6800 / 6900 series (gfx1030). Integrated
Radeon GPUs are listed as not supported.
On Linux setup uses a system ROCm 7 when there is one, or installs ROCm into `.venv` from AMD's wheels (~10 GB, no
sudo), and compiles the engine on your PC for the card (10-20 minutes, once; it needs a C++ compiler and git:
`sudo apt install build-essential git`). Several AMD cards share the model with `--gpus`, as on NVIDIA.
What differs from NVIDIA for now: pictures are read by the image encoder on the CPU (`--vision cpu`, 10-30 s per
picture), `--calibrate` is NVIDIA-only, and Unsloth's 4-bit model needs an NVIDIA card. Measurements per card, the
build by hand and the tuning tables: [AMD_HIP.md](AMD_HIP.md).
## Two or three cards
**Two or three NVIDIA cards?** Just run `START-HERE.bat`: it lists your cards, says which ones Strata can use, and
asks whether to share the model across them (recommended when two can). An install made on one card asks once at
its next start. Or choose yourself: `START-HERE.bat --gpus 0,2` (both, remembered), `--gpus all`, or `--gpu 0` (one
card, this start only). Each card keeps the experts of its own layers, and prompts flow through the cards in a
pipeline: on an RTX 5080 + RTX 3090 prompts were read 18-20% faster than on the 5080 alone, decoding on par.
Every card must be an RTX 20 series or newer with 8 GB or more. See [MULTI_GPU.md](MULTI_GPU.md).
**Several AMD cards:** setup takes one card (the one with the most VRAM, or `--gpu N`) unless you name more:
`--gpus 1,0` splits the model's layers across them, the first one the main card (numbers as setup lists them). A
split pays only when no single card holds the model's experts ([AMD_HIP.md](AMD_HIP.md#rdna4-gfx1201)).
## Docker (Linux)
The same idea, in a container (NVIDIA cards).
1. Host: Docker with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
and a driver **580 or newer** (CUDA 13.0).
2. Build (this compiles the engine into the image, so the container never compiles):
`docker build -t strata .`
`docker build -t strata --build-arg CUDA_ARCHITECTURES=89 .` builds for one card only (faster).
The default covers RTX 30 (86), RTX 40 (89), RTX 50 (120) and A-series (80); a card outside that
set needs a rebuild with its own arch. Add `--build-arg BUILD_VISION=0` to skip the image encoder.
3. Run (the first start downloads the ~70 GB model, then starts; later starts go straight to serving):
`docker run --rm --gpus all -p 8080:8080 --ulimit memlock=-1 -v strata-data:/data strata`
The setup choices are env vars: `-e MODEL=IQ2_XS -e FAMILY=qwen -e CONTEXT=32768 -e VISION=no`
(or `MODEL=Q2_0|IQ3_XXS|IQ3_S`, `FAMILY=swift|coder`; the defaults above are the recommended ones).
`-e VISION=cpu` keeps the image encoder on the CPU. `-e KV=int8|q4_0|k8v4` picks the KV cache
precision; `k8v4` is INT8 K with 4-bit V and keeps its KV in VRAM from 64K up.
Only the model files, the prepared pack, the MTP layer and the install config live in the
`strata-data` volume; the engine is part of the image. Switching between models already on the
volume needs no setup pass: `-e MODEL=Q2_0 -e FAMILY=coder` picks that model's config. Add
`-e REINSTALL=1` only to change settings for a model already set up (context, vision, KV, host,
api_key, LOW_RAM), since those are recorded in its config.
Strata loads 32-62 GB into RAM. `--gpus all` on a host with two usable cards takes both: the
layer split is setup's recommended default ([MULTI_GPU.md](MULTI_GPU.md)), and a volume
set up for one card switches to the pair on its first start there. Pin one card with `-e GPU=0`,
or name them with `-e GPUS=0,2` and where the later card's layers start with `-e LAYER_SPLIT=18`.
A memory limit needs `-e LOW_RAM=on`, which maps the model's experts from the pack instead of
keeping them in RAM: setup.py measures the host's RAM, not the container's limit, so it cannot
see a cap. LOW_RAM runs on one card unless `GPUS` names several (then the experts the cards do not hold are
read through the OS file cache, which can fill the RAM during long prompts).
The server listens on `0.0.0.0:8080` by default; set `-e API_KEY=<secret>` before exposing the port
to a network. The image has a `HEALTHCHECK` on `/health`, so `docker ps` shows the container
healthy once the model is loaded, and `GET /v1/status` says what it is running.
## Updating
**`UPDATE.bat`** (Linux: `./update.sh`) updates Strata without starting the model - for when the GPU is busy with
something else, or you just want the new version ready. In a `git clone` it runs `git pull`, then does what
`START-HERE.bat` does before a start: the engine (a new ready-made one when the new version needs it; on Linux a
compiled engine is compiled again when its source changed), the Python packages, and each installed model's settings
and draft subset. The model files are not touched (at most a new engine is downloaded) and no question is asked.
Close the model's window first (a running engine cannot be replaced); start the model later with `START-HERE.bat` as
usual. In a copy that was downloaded as a zip it says to download the new zip (below): it cannot fetch new files
itself.
Or by hand: download the new version and unzip it anywhere (or `git pull`), then run `START-HERE.bat` (Linux:
`./setup.sh`) in it. The model files are kept in a `Strata-data` folder next to your Strata folder, so a new copy
finds them and sets itself up the same way - nothing big is downloaded again. On Linux after a `git pull`, setup
compiles the engine again when its source changed (a minute or two for the changed files); if that compile fails, it
says so and starts the engine you had.
## Where things are stored
- **Your chats: only in your browser.** The Chat tab keeps the conversation, its settings and the API key you typed
in the browser's local storage (`strata.*` keys) - not on the server and not in the Strata folder. Pictures are not
kept, only their names. Another browser or a private window starts empty; clearing the site's data deletes them.
- **How the model starts:** `strata-<model>.json` in the Strata folder (context, GPUs, host, API key, ...), written
by setup; next to it `run-<model>.bat` / `.sh`, the log `strata-<model>.log` and, when you use "Use for other
apps too", `strata-<model>.shared-settings.json`.
- **The model files** (`models/`, `packs/`, `mtp/`, 70-120 GB): in **`Strata-data` next to the Strata folder**, or
wherever `--data-dir` put them.
- **Where that data folder is:** `%APPDATA%\Strata\settings.json` on Windows, `~/.config/strata/settings.json` on
Linux ([details](DETAILS.md#before-you-start)).
- **The program itself:** `.venv/`, `engine/` and `third_party/` in the Strata folder.
## Setup's questions
- **Which model and size?** The original, Swift 1.5, the Coder or Unsloth's 4-bit, and Q2_0, IQ2_XS, IQ3_XXS or
IQ3_S - see [which model](MODELS.md).
- **How much context?** How much text it can keep in mind at once (it suggests one for your card). 384K and
512K (experimental) extend the model past its trained 262K by rope scaling - the setup turns it on itself (yarn and a
covering factor; `--rope-scaling`/`--rope-scale` override) ([details](DETAILS.md): "Context extension past
262K").
- **Images?** Whether it should also read pictures. The image encoder (0.9 GB) runs on the GPU (0.1-0.5 s per
picture, ~1.4 GB of VRAM kept free for it) or on the CPU (10-30 s per picture, nothing on the GPU); with AMD cards
on the CPU for now. [Details](DETAILS.md#images-vision).
- **Experimental speed projection?** Off unless you say yes - [read what it does](DETAILS.md#experimental-speed-projection-experimental-off-by-default)
first. It changes how the model answers, and only the original model offers it.
With `--yes` setup takes the recommended answer to every question.
## Tuning for your PC
Every PC is different: `START-HERE.bat --calibrate` (Linux: `./setup.sh --calibrate`) measures a few engine settings
on yours and keeps the fastest (about 5-10 minutes; on an RTX 5070 with a Ryzen 5 7600 it made the Coder 7% faster).
It keeps a setting only when it is more than 3% faster, and the result is remembered per PC and model, so updates
keep it. NVIDIA cards for now. [What it measures](DETAILS.md#double-click-start-herebat).
## Options without questions
```
START-HERE.bat --setup install another model, or change context / images
SETUP.bat the same (double-click it)
START-HERE.bat --model IQ2_XS --context 32768 --vision yes --yes no questions
START-HERE.bat --gguf-dir D:\models\IQ2_XS use GGUF files you already have
START-HERE.bat --data-dir E:\Strata-data keep the model files somewhere else
START-HERE.bat --port 8081 another port
START-HERE.bat --gpu 1 another GPU (setup picks the one with the most VRAM)
START-HERE.bat --gpus 0,2 several GPUs sharing the model
START-HERE.bat --setup --backend hip the AMD engine on a PC that also has an NVIDIA card
START-HERE.bat --setup --host 0.0.0.0 --api-key <secret> reachable from other devices, with a key
START-HERE.bat --calibrate tune the engine for this PC (about 5-10 minutes), then start
START-HERE.bat --check only check this PC
```
On Linux the same options go to `./setup.sh`. `START-HERE.bat --help` lists them all. The server's own settings
(sharing the GPU with games, MCP tools, CORS, API keys, the API itself) are in the [details](DETAILS.md#using-it).
+205
View File
@@ -0,0 +1,205 @@
# Strata's MCP server: let your AI assistant install and run Strata
`tools/strata_mcp.py` is an [MCP](https://modelcontextprotocol.io) server for the AI assistant you already use
(Claude Code, Claude Desktop, Cursor, VS Code, Codex ...). Add it once, then ask in plain words:
- "Install Strata for this PC."
- "Start Strata." / "Stop Strata."
- "Is Strata running? How much VRAM does it use?"
- "Which Strata model fits my PC?"
- "Strata didn't start - what does the log say?"
- "How fast is Strata on this PC?" (a short speed test)
- "How do I connect Open WebUI / the OpenAI SDK / Claude Code to Strata?"
The assistant calls the server's tools. The tools run Strata's own setup and start the model the way the start
scripts do. It is one Python file that uses only the standard library, so it works before setup has created
`.venv`. It needs Python 3.10 or newer, 64-bit on Windows.
It is not the same thing as [Tools from MCP servers](DETAILS.md#tools-from-mcp-servers). That feature
(`serve/mcp.py`) lets the *Strata model* call tools from your MCP servers in its chat page. This page is the other
direction: *your assistant* manages Strata.
## Add it
You need a copy of Strata: the zip from GitHub, or `git clone https://github.com/Niko1221/Strata`. Below,
`C:\Users\you\Strata` (Windows) and `/home/you/Strata` (Linux) stand for that folder. Use `python3` on Linux. On
Windows, use `py` if `python` is not on your PATH.
### Claude Code
```bash
claude mcp add strata -- python C:\Users\you\Strata\tools\strata_mcp.py # Windows
claude mcp add strata -- python3 /home/you/Strata/tools/strata_mcp.py # Linux
```
Add `--scope user` to have it in every project. `/mcp` in Claude Code shows that it is connected.
### Claude Desktop
Open Settings > Developer > Edit Config (`claude_desktop_config.json`) and add:
```json
{
"mcpServers": {
"strata": {
"command": "python",
"args": ["C:\\Users\\you\\Strata\\tools\\strata_mcp.py"]
}
}
}
```
On Linux, use `"command": "python3"` and `"args": ["/home/you/Strata/tools/strata_mcp.py"]`. Restart Claude
Desktop after the change.
### Cursor
Use `~/.cursor/mcp.json` for every project, or `.cursor/mcp.json` in one project. The format is the same as
Claude Desktop's:
```json
{
"mcpServers": {
"strata": {
"command": "python",
"args": ["C:\\Users\\you\\Strata\\tools\\strata_mcp.py"]
}
}
}
```
### VS Code (GitHub Copilot agent mode)
Use `.vscode/mcp.json` in a workspace, or run "MCP: Open User Configuration" from the command palette:
```json
{
"servers": {
"strata": {
"type": "stdio",
"command": "python",
"args": ["C:\\Users\\you\\Strata\\tools\\strata_mcp.py"]
}
}
}
```
On Linux, use `"command": "python3"` and `"args": ["/home/you/Strata/tools/strata_mcp.py"]`.
### Codex CLI
In `~/.codex/config.toml`:
```toml
[mcp_servers.strata]
command = "python"
args = ['C:\Users\you\Strata\tools\strata_mcp.py'] # Linux: command = "python3", args = ["/home/you/Strata/tools/strata_mcp.py"]
tool_timeout_sec = 180 # strata_stop / strata_benchmark can take longer than 60 s
```
### Any other MCP client
The command is `python <Strata folder>/tools/strata_mcp.py`. The transport is stdio. It speaks MCP revision
2025-06-18, and also 2025-03-26 and 2024-11-05.
`--root <folder>` manages a Strata folder other than the one the file is in. For example:
`python D:\tools\strata_mcp.py --root D:\Strata`.
## The tools
| Tool | What it does | Arguments (all optional) |
| --- | --- | --- |
| `strata_status` | Whether a model is running: the model, engine version, context, busy or idle, VRAM and RAM in use. What is installed: models, start scripts, engine version, data folder, downloads. An install in progress. This PC's hardware: OS, CPU, RAM, GPUs, page file, free disk. The model and context setup recommends for this PC. | `port`, `hardware` (true/false) |
| `strata_models` | Every family and size setup offers, with download size, RAM needed, whether it fits this PC, and whether it is installed. Also the context lengths and the recommendation. | - |
| `strata_install` | Runs setup without questions, in the background (see below). Without `confirm: true` it only returns the plan. A second call shows the progress. `cancel: true` stops the install. | `family` (qwen, swift, coder, unsloth), `model` (Q2_0, IQ2_XS, IQ3_XXS, IQ3_S, IQ1_M, UD-Q4_K_XL), `context` (8192 ... 524288), `vision` (no, yes, gpu, cpu), `kv` (int8, q4_0, k8v4), `gpu` (a number) or `gpus` ("0,2", "all"), `backend` (auto, cuda, hip), `low_ram`, `port`, `data_dir`, `confirm`, `cancel` |
| `strata_start` | Starts an installed model in the background and waits until it answers. If it is not ready after `wait_seconds` (default 50), it returns "loading". | `model` (an id from `strata_status`, e.g. `q2_0`, `coder-iq1_m`; default: the most recently used), `port`, `gpu`, `wait_seconds` (0-600) |
| `strata_stop` | Unloads the model through the server's own API, then ends the server it started. | `port`, `force` |
| `strata_logs` | The last lines of the server log, the engine log or the setup log. | `source` (server, engine, setup), `model`, `lines` (1-500) |
| `strata_benchmark` | Sends a short fixed request (greedy, thinking off) and reports output and prompt tokens/s. | `port`, `max_tokens` (16-512) |
| `strata_connect_info` | The OpenAI and Anthropic base URLs, the model id, whether a key is needed, and settings for Claude Code, Codex, the OpenAI SDK, curl, Cursor, Continue and Open WebUI. | `port` |
Every result is JSON with a one-line `summary`. It is sent both as text and as MCP structured content.
## How it works
**Install.** `strata_install` uses setup's own logic and tables (it imports `setup.py`'s `MODELS`, `FAMILIES`,
`CONTEXTS` and RAM checks) to fill in what you left out:
- the size setup itself would pick: IQ3_XXS from 60 GB of RAM, Q2_0 from about 44 GB, the Coder from about 28 GB,
and the Coder in the low-RAM mode below that when the GPU makes up for the RAM;
- the context setup recommends for the GPU's VRAM;
- images off.
The first call returns the plan: the download size (58-111 GB), the free disk space, the RAM check, and the exact
setup command. The assistant should show you the plan and call again with `confirm: true` once you agree.
It then runs the same steps `START-HERE.bat` / `setup.sh` run:
1. It creates `.venv` with the Python it runs on, if `.venv` is missing.
2. It runs `setup.py --yes --no-start --family ... --model ... --context ... --vision ...` as a background process.
The output goes to `.strata-mcp/setup.log` in the Strata folder.
The tool returns at once. Call it again to see the step (1-7), the download percentage, or setup's error message.
Everything setup has finished is skipped on the next run, and downloads resume. If the AI app closes, the install
keeps running. The install is refused while a model this server started is running, because setup may replace the
engine files that model uses.
**Start.** `strata_start` runs the same command as the `run-<model>` script, `serve/server.py --engine strata
--config strata-<model>.json --port N`, with the venv's Python, without `--open` (no browser) and without a console
window. The server's output goes to `.strata-mcp/server.log`. The tool then polls `GET /health` until the model
answers. Loading takes 1-3 minutes. If the server ends while loading, the tool returns the end of its log. The model
keeps running after the AI app closes, until `strata_stop`. If the AI app runs its tools inside a Windows job that
does not allow processes to leave it, the model ends together with the app.
Two cases are refused:
- a model is already running: the tool returns it and starts nothing;
- another program uses the port: the tool says so.
**Stop.** `strata_stop` first sends `POST /unload`. The server sends the engine `QUIT`, and the engine frees its
VRAM and pinned RAM itself, as when you close the window. Then the server ends:
- Linux: SIGTERM, which runs the server's own Ctrl+C path.
- Windows: the process ends. Its job object ends the vision encoder and the MCP servers it started.
While a request is running, the tool refuses unless you pass `force: true`.
**Status.** The tool reads `GET /health`, `/v1/status`, `/status` and, on older servers, `/metrics` on 127.0.0.1.
If a config of the folder sets an `api_key`, the tool sends that key with its own requests. It never shows the key.
## Safety
- **No shell, no free paths.** Every argument is checked against a fixed schema and against setup's own choices. An
unknown argument, a model or family setup does not offer, or a `gpus` value other than digits and commas is
refused before anything runs. Commands are passed as argument lists; nothing goes through a shell.
- **Paths.** The only path argument is `data_dir`. It must be one of:
- the current Strata data folder;
- a folder inside the Strata folder;
- a new absolute folder whose name starts with `Strata` (e.g. `D:\Strata-data`), whose parent exists, and which is
not in a system folder.
`..`, relative paths and network (UNC) paths are refused. Logs are read only from `.strata-mcp/` and from the
engine log a config names, if that log is inside the Strata folder or the data folder.
- **Only its own processes.** The server records the process id *and start time* of every process it starts, in
`.strata-mcp/*.json`. It only ends a process when both match, so a process id the OS has given to another program
is never touched. A Strata started some other way (its window, a run script, a service) is only asked over its
API to unload the model. To end that server, close its window.
- **Big downloads need a yes.** `strata_install` does nothing until it is called with `confirm: true`. The plan
shows the download size and the free space first, and the install is refused if the disk is too small.
- **Local only.** The server checks `127.0.0.1` and never changes the network settings: it does not install with
`--host 0.0.0.0` or an API key. To share Strata on your network, run setup yourself (see
[Using it](DETAILS.md#using-it)). Experimental options are left off too: the speed projection, rope scaling past
262K and `--build`. Run setup by hand for those.
## Troubleshooting
- **The assistant does not see the tools.** Run the command by hand:
`python C:\Users\you\Strata\tools\strata_mcp.py`. It should print `[strata-mcp] ready (Strata folder ...)` to
stderr and wait for input (Ctrl+C ends it). If it reports `not a Strata folder`, the path is wrong or `--root` is
needed. Python older than 3.10 is refused.
- **The install failed.** Ask the assistant for the setup log (`strata_logs` with `source: setup`), or open
`.strata-mcp/setup.log`. Setup's `[X]` line says what to fix. Then install again: every finished step is skipped.
- **The model does not start.** Get the server log with `strata_logs`, and the engine log with `source: engine`.
[Troubleshooting](DETAILS.md#troubleshooting) has the usual causes.
- **Tests.** `python -m unittest tools/test_strata_mcp.py` runs the tests with a fake setup and server. They do not
download anything or use the GPU.
+139
View File
@@ -0,0 +1,139 @@
# Which model? Sizes, versions and what fits
Strata runs one model, [Qwen3.8-Flash-Next](https://huggingface.co/Qwen/Qwen3.8-Flash-Next), in several **sizes**
(the same model, compressed more or less) and several **versions** (the original, a coding version, a fine-tune).
The installer recommends one for your PC; this page explains the choice. Back to the [README](../README.md).
> **On this page:** [Pick by RAM](#pick-by-ram) · [Speed](#how-fast-is-each-size) · [The sizes](#the-sizes) ·
> [Will it fit?](#will-it-fit) · [The versions](#the-versions) · [Adding another model](#adding-or-switching-models)
## Pick by RAM
| Your RAM | Take | Why |
| --- | --- | --- |
| **32 GB** | **Coder** | it fits 32 GB, and it is made for code (with a 24 GB card, Q2_0 and IQ2_XS run too: [low-RAM mode](#a-big-graphics-card-and-little-ram)) |
| **48 GB** | **IQ2_XS** (or Q2_0, the fastest) | the larger sizes do not fit |
| **64 GB** | **IQ2_XS** (recommended), or IQ3_XXS / IQ3_S | every size fits (IQ3_S with little else open) |
| **96 GB or more** | **IQ3_S**, or [Unsloth's 4-bit](#unsloth-ud-q4_k_xl-experimental) (experimental) | room for the largest sizes |
Not sure? Take **IQ2_XS** - or the **Coder** if you mainly write code, or have 32-48 GB of RAM.
## How fast is each size
Measured on an RTX 5070 (12 GB), a Ryzen 5 7600 and 64 GB of RAM:
| Size | Writes answers (short chat) | Writes answers (128K context) | Reads your prompt |
| --- | ---: | ---: | ---: |
| **Q2_0** | 94 tokens/s | 76 tokens/s | 2,650 tokens/s |
| **IQ2_XS** | 79 tokens/s | 63 tokens/s | 2,090 tokens/s |
| **IQ3_XXS** | 62 tokens/s | 49 tokens/s | 1,750 tokens/s |
| **IQ3_S** | 53 tokens/s | 46 tokens/s | 1,620 tokens/s |
| **Coder** (IQ1_M) | 55 tokens/s | 43 tokens/s | 2,180 tokens/s |
Measured on an AMD RX 9070 XT (16 GB), a Ryzen 9 3900X and 47 GB of RAM (Linux, setup's own install; Q2_0 is
above setup's RAM estimate for 47 GB and was installed with `--model Q2_0 --yes`):
| Size | Writes answers (short chat) | Writes answers (128K context) | Reads your prompt |
| --- | ---: | ---: | ---: |
| **Q2_0** | 60 tokens/s | 48 tokens/s | 1,160 tokens/s |
| **IQ2_XS** | 52 tokens/s | 36 tokens/s | 1,110 tokens/s |
| **Coder** (IQ1_M) | 44 tokens/s | 33 tokens/s | 1,420 tokens/s |
- **Writes answers** = how fast the reply appears (tokens per second; a token is about ¾ of a word).
- **Reads your prompt** = how fast it takes in what you send (long documents, code, chat history), measured on a
32K-token prompt; a 4K prompt reads at 910-1,580 tokens/s. A 32K prompt takes about 15 seconds with Q2_0.
A card with more VRAM is faster, because more of the model fits on the GPU: an RTX 3090 (24 GB) should do roughly
100-140 tokens per second. All measurements, long-context numbers and estimates for other cards are in the
[details](DETAILS.md#speed-measured). The AMD measurements per card (RX 9070 XT, Radeon AI PRO R9700, RX 7800 XT,
RX 9060 XT, RX 6900 XT) are in [AMD_HIP.md](AMD_HIP.md#rdna4-gfx1201).
Every PC is different: `START-HERE.bat --calibrate` measures a few engine settings on yours and keeps the fastest
(about 5-10 minutes; on the PC above it made the Coder 7% faster; NVIDIA cards for now). Measured Strata on your own
PC? See [Community benchmark results](COMMUNITY_BENCHMARKS.md) for a report template and how to share your results
in a pull request.
## The sizes
| Model | RAM+VRAM Requirements | Speed | Quality |
| --- | ---: | --- | --- |
| **Q2_0** | 37.6 GB | fastest | good |
| **IQ2_XS** | 39.2 GB | fast | better (**recommended**) |
| **IQ3_XXS** | 47.0 GB | slower | great |
| **IQ3_S** | 54.8 GB | slowest | best: matches the full model on the published tests (original model only) |
The download is 66-76 GB for the three smaller sizes ([details](DETAILS.md#which-model)); the first start also
fetches the MTP draft layer (~6 GB, +1 GB with images).
## Will it fit?
Shard 1 is the part of the model that gets loaded when it starts: its experts go into your **RAM**, the rest onto
your graphics card (the second shard, a 29 GB lookup table, stays on the SSD). So it fits when your **RAM is at least
shard 1 + about 10 GB** for Windows and your other programs. With 64 GB of RAM every size fits (IQ3_S with little
else open); with 48 GB, Q2_0 and IQ2_XS. A bigger graphics card makes it faster, but it doesn't lower the RAM needed
- except in the low-RAM mode below.
### A big graphics card and little RAM
On a PC whose RAM cannot hold the experts beside the system, setup maps them from the model's files instead and keeps
in RAM only what the graphics card does not hold (the [low-RAM mode](DETAILS.md#speed-measured), chosen by setup). For
example, a 32 GB PC with a 24 GB GPU runs Q2_0, IQ2_XS and the Coder this way, and a 32 GB PC with a 12-16 GB GPU the
Coder. With a small card most experts then come from the SSD and it is much slower (setup says so).
`START-HERE.bat --setup --low-ram on|off` overrides the choice.
## The versions
### Qwen3.8-Flash-Next
The original, in all four sizes. With images, and with the
[experimental speed projection](DETAILS.md#experimental-speed-projection-experimental-off-by-default) as an option.
### Coder
**[Coder](https://huggingface.co/ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-Coder-GGUF)** - ISTA-DASLab's coding version:
half of the experts removed, keeping the ones that code, tool use and images need (91% of the full model's SWE-bench
Verified score, 99% of LiveCodeBench, by its authors). One size (IQ1_M: its experts stored like IQ3_S): shard 1 is
**29.6 GB**, so it fits a PC with **32 GB of RAM**, runs 262K context on 64 GB, and reads long prompts the fastest of
all. It is weaker outside code, and that includes Chinese and other CJK text (#438: Chinese answers came out wrong
or looping where English was fine). For general chat or CJK text, take a size that keeps every expert: Q2_0,
IQ2_XS or IQ3_S.
More: [details](DETAILS.md#or-the-coder-half-the-experts-for-code).
```
START-HERE.bat --setup --family coder
```
### Swift 1.5
**[Swift 1.5](https://huggingface.co/ukisai/Swift-1.5-Qwen3.8-Flash-Next-GSQ-RCO-GGUF)** - a fine-tune by UkisAI that
thinks much shorter before answering, so you get the answer sooner, with about the same quality. Same speed per
token, and about the same RAM as the same size of the original (no IQ3_S). Its own license applies (see its page).
More: [details](DETAILS.md#or-swift-15-a-fine-tune-that-thinks-shorter).
```
START-HERE.bat --setup --family swift --model IQ2_XS
```
### Unsloth UD-Q4_K_XL (experimental)
**Unsloth's 4-bit UD-Q4_K_XL** (experimental) is the fourth version in setup's menu (`--family unsloth`): the closest
to the full model, but a 111 GB download whose 77 GB of experts do not fit in RAM. Strata keeps your RAM minus 24 GB
of them in RAM and reads the rest from the SSD while it answers: 7-8.5 tokens/s on a 64 GB PC with a 12 GB GPU, several
times slower than the sizes above, and long prompts are slow. It needs 48 GB of RAM or more, an NVMe SSD and one
NVIDIA GPU (no images yet). Details and measurements: [UD-Q4_K_XL](UNSLOTH_Q4.md).
```
START-HERE.bat --setup --family unsloth --model UD-Q4_K_XL
```
### OrcaRouter Uncensored IQ3_XXS
For **OrcaRouter's Flash-Next Uncensored IQ3_XXS**, see the [manual compatibility setup](ORCA.md). It needs an
explicit packing conversion and is not an installer menu option.
## Adding or switching models
You can add another model any time with `SETUP.bat` (the same as `START-HERE.bat --setup`; on Linux
`./setup.sh --setup`). Files another model shares are not downloaded again (the Coder uses the original's shard 2 and
vision encoder). With more than one model installed, `START-HERE.bat` asks which one to start; `run-<model>.bat`
(Linux: `run-<model>.sh`) starts one directly.
+11 -1
View File
@@ -43,7 +43,8 @@ now on; the answer is kept.
**Not supported** (setup says so and names the cards that can be used instead):
- a card older than the RTX 20 series (compute capability below 7.5: GTX 10 and older);
- a card with less than 8 GB of VRAM, together with others (each card holds a copy of the dense weights and its
own prompt buffers);
own prompt buffers) - unless you name it with `--gpus`: then setup says the risk and asks (`--yes` with the named
cards goes ahead);
- Intel GPUs, and a mix of NVIDIA and AMD cards. (AMD cards share a model among themselves: `./setup.sh --backend
hip --gpus 1,0`, see [AMD_HIP.md](AMD_HIP.md).)
@@ -60,6 +61,15 @@ profiled expert plus the context's KV, the draft layer and the reserve, and says
stays. On an R9700 32 GB + RX 9070 XT the R9700 holds all of the Coder's experts: with the flag 4K prompts read at
1,776 tok/s instead of 1,244 (split) and decode runs at ~60 tok/s instead of ~51 (16K prompts ~5% slower than split).
**Short prompts on a split (0.1.32, #340).** 0.1.30 gave each card's prompt path a loan from its own expert cache,
refilled after every request; on cards that hold nearly all their experts that cost short prompts up to a third of
their speed. 0.1.32 refills all cards at once, uses a smaller streaming ring on a split, and lets a card with free
VRAM keep its own prompt buffers - the same output as 0.1.31, measured on an R9700 + RX 9070 XT: 2K prompts 993 ->
1,265 tok/s, 16K 1,852 -> 1,950, decode unchanged. `STRATA_SPLIT_OWN=1` (opt-in) gives every card its own buffers:
2K 1,450 and 16K 2,227 tok/s there, but a full card then keeps a different set of experts resident, so the output
differs from the default's (stable and coherent); `STRATA_SPLIT_OWN=auto` does that only where the buffers are at
most 12% of each card's VRAM.
The engine flags behind it: `--layer-split K1[,K2..]|auto` and `--split-device D1[,D2..]` (the later stages'
devices; default the next visible ones). `--layer-split K --split-device 0` runs both stages on one card sharing
everything - the bit-exact check of the hand-off, not a speed mode.
+36
View File
@@ -0,0 +1,36 @@
# OrcaRouter Uncensored Q4_K_S (manual setup)
This path uses the three original `orcarouter/Qwen3.8-Flash-Next-Uncensored-GGUF` Q4_K_S shards without rewriting their expert, embedding or PLE table bytes. It is separate from the installer model menu. Keep all three shard filenames together.
The ordinary GGUF quantizes some small projections which Strata reads as BF16. `--compat-bf16` dequantizes and rounds those projections to BF16 in the separate pack; it does not restore their original precision. The Q5_0 PLE table remains in the source GGUF and currently uses the mapped reader (`--ple-io mmap`). The Q4_K gate/up experts and Q5_0/Q5_1 down experts remain in GGUF form, including layers whose three tensors cross shard boundaries (each role is read from its own shard). The GPU expert kernels take Q4_K with Q5_1 (or Q8_0) downs; Q5_0 downs are not among them yet, and the engine refuses to start on a pack with such a layer (it names the layer).
## Build and prepare
Build Strata with CUDA, native experts, `STRATA_ORCA_Q4KS_MMQ=ON` (and `STRATA_BUILD_TESTS=ON` for the two parity programs below). This opt-in compiles the Q5_0 GGML MMQ instance for CUDA or HIP (the Q4_K and Q5_1 instances come with the engine's K-quant MMQ build on CUDA); the usual Q8_0 draft-layer instance remains in both builds. HIP also needs `STRATA_PREFILL_MMQ=ON`. Select your card's compute capability, for example `89` on an L40S. On Windows with Ninja and CUDA 12.6, use the Visual Studio developer environment and `-DCMAKE_CUDA_RUNTIME_LIBRARY=Shared` to keep the CUDA runtime linkage consistent. The default pinned ggml checkout is supported by the project build.
```sh
cmake -S . -B build -G Ninja -DSTRATA_ENABLE_CUDA=ON -DSTRATA_NATIVE_EXPERTS=ON -DSTRATA_ORCA_Q4KS_MMQ=ON -DCMAKE_CUDA_ARCHITECTURES=89 -DCMAKE_CUDA_RUNTIME_LIBRARY=Shared -DCMAKE_BUILD_TYPE=Release
cmake --build build --target strata native_expert_parity ple_q5_parity
python tools/iq_pack.py --gguf /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00001-of-00003.gguf --out packs/orca-q4_k_s --compat-bf16
```
`iq_pack.py` needs NumPy and the `gguf-py` module from the pinned llama.cpp dependency (`STRATA_GGUF_PY` can point to that directory). It writes a roughly 1.43 GiB dense pack and a native-expert manifest. The original 112 GB of model shards are read in place; `experts.bin` is optional and is not written by this command.
Check the changed kernels against the real files before starting the service:
```sh
build/native_expert_parity /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00001-of-00003.gguf /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00002-of-00003.gguf /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00003-of-00003.gguf 0 5 6 20 47
build/ple_q5_parity /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00001-of-00003.gguf
```
## Engine flags
Pass the same first shard to `--native` and `--ple-gguf`; Strata discovers the other two shards by name. For a first text-only run, use 32K context and a modest cache. Once that passes, increase the context and cache with memory measurements on the target machine.
```sh
--pack packs/orca-q4_k_s --native /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00001-of-00003.gguf --ple-gguf /models/Qwen3.8-Flash-Next-Uncensored-Q4_K_S-00001-of-00003.gguf --ple-io mmap --expert-profile data/expert-profile.bin --expert-cache auto --prefill 512 --spec 4 --mtp /path/to/mtp/rt --max-context 32768 --kv int8
```
For two GPUs in persistent server mode, add `--layer-split auto --split-device 1` (with CUDA0 and CUDA1 visible). The usual Strata MTP runtime is built from the original model's draft layer; draft acceptance on the Uncensored fine-tune must be measured. Keep this model's own tokenizer from the pack. Do not combine `--expert-cache-per-layer` with a variable-size native cache: that policy's slot ordering is independent of the pack's per-layer blob sizes.
This port does not claim that Q4_K_S beats llama.cpp on a given computer. Compare the same model bytes, prompt, context, output length and GPU load; report prefill and decode separately.
+70
View File
@@ -0,0 +1,70 @@
# Something went wrong?
The common problems and what to do. Back to the [README](../README.md#something-went-wrong). The full table of
error messages, with the older engine fixes, is in the [details](DETAILS.md#troubleshooting).
## While installing or starting
**My PC froze, or got very slow, the first time Strata started.**
That's normal while it starts, most of all the first time. Strata loads 35-55 GB into your RAM, locks part of it for
the graphics card, and works out how much of the model fits on your GPU. The mouse can freeze for a few minutes.
**Wait, and don't close the window.** The next starts are much faster. Still frozen after 10 minutes? Restart the
PC, close other programs (browsers use a lot of RAM) and try again. If it keeps happening, pick a smaller size (Q2_0
or IQ2_XS).
**It stopped while downloading or installing.**
Run `START-HERE.bat` (Linux: `./setup.sh`) again. It continues where it stopped.
**It says the NVIDIA driver is too old.**
Update it (NVIDIA App or [nvidia.com/drivers](https://www.nvidia.com/drivers)), restart the PC, and run
`START-HERE.bat` again.
**It says port 8080 is already in use.**
Strata is already running. Look for its window. Or another program uses the port: `START-HERE.bat --port 8081`.
**Python or the build tools could not be installed.**
Install what it names (links are printed), then run it again. Everything already done is kept.
**The first start takes minutes.**
It is reading 34-55 GB into RAM; the second start is faster while the files are in the OS cache. Started from Task
Scheduler, it can be 24x slower: see [Running it at startup](DETAILS.md#running-it-at-startup-task-scheduler).
## While it answers
**It's very slow and the disk light keeps blinking.**
Your PC is out of free RAM. Close other programs, or pick a smaller size (Q2_0 or IQ2_XS).
**An answer stopped with "the engine stopped unexpectedly".**
Usually not enough RAM (on Linux the system then stops the engine). Just send your message again: Strata starts the
engine by itself. If it keeps happening, close other programs or pick a smaller size.
**It says the prompt exceeds the context.**
The conversation is longer than the context you chose. Start a new chat, or run `SETUP.bat` and pick more
context.
**It is slower than the tables.**
The monitor plugged into the graphics card and other GPU programs take VRAM from the expert cache; RAM running below
its rated speed (enable EXPO/XMP in the BIOS) slows the CPU half.
**Pictures are refused, or slow.**
"this server was started without the vision encoder": the model was set up for text only - run setup again with
`--vision gpu` (or `--vision cpu`). Pictures that take 10-30 s are read by the encoder on the CPU; `--vision gpu`
(NVIDIA, ~1.4 GB of VRAM) makes it 0.1-0.5 s.
## AMD cards
**"No AMD GPU found (the amdgpu driver's KFD topology is empty)" on Linux.**
The kernel's amdgpu driver is not loaded for the card. Integrated Radeon GPUs are listed as not supported; setup
lists every card it found and whether Strata can use it.
**The engine stops at start with the card's name, its architecture and the build's list.**
The engine was compiled for another card (for example after moving the Strata folder to another PC). Run
`./setup.sh --setup --backend hip`: it compiles the engine for this card's architecture.
**Large pinned host allocations fail on ROCm although RAM is free.**
See [AMD_HIP.md](AMD_HIP.md#model-and-serving-configuration): the mapped expert mode avoids the full pinned arena.
## Still stuck?
Look in the [full troubleshooting table](DETAILS.md#troubleshooting), or open an
[issue](https://github.com/Niko1221/Strata/issues) and attach `strata-<model>.log` from the Strata folder.
+91 -15
View File
@@ -1,8 +1,8 @@
# Unsloth UD-Q4_K_XL (experimental, manual import)
# Unsloth UD-Q4_K_XL (experimental)
**Experimental, not in setup yet.** Engine 0.1.31 or newer. Validated on one PC: Windows 10, an RTX 5070 (12 GB),
64 GB of RAM and an AVX-512 CPU, on 2026-10-01. Everything below is a manual workflow; `START-HERE.bat` / `setup.sh`
do not offer this model.
**Experimental.** Setup offers it from engine 0.1.32 ([below](#setup)); the manual workflow after that section works
with engine 0.1.31 or newer. Validated on one PC: Windows 10, an RTX 5070 (12 GB), 64 GB of RAM and an AVX-512 CPU,
on 2026-10-01.
The target is Unsloth's 4-bit quantization of the same model the other packs use:
[unsloth/Qwen3.8-Flash-Next-GGUF, `UD-Q4_K_XL`, revision `38bb39e`](https://huggingface.co/unsloth/Qwen3.8-Flash-Next-GGUF/tree/38bb39ee97821de2c9009abb7e93950eec396e66/UD-Q4_K_XL).
@@ -11,6 +11,31 @@ Q5_1 (Q8_0 in layers 2, 4, 30, 46, 47) for down; the token embedding, the output
projections and the PLE key are Q8_0; the 28.8 GB PLE table is IQ4_NL. The routed experts are 71.7 GiB, about twice
the 3-bit models'.
## Setup
`START-HERE.bat --setup` (Linux: `./setup.sh --setup`) and choose **Qwen3.8-Flash-Next (Unsloth)**, marked
`[experimental]`; or directly:
```sh
START-HERE.bat --setup --family unsloth --model UD-Q4_K_XL
```
What setup does differently for this model:
- It needs 48 GB of RAM or more (with less it asks, default no; `--model UD-Q4_K_XL --yes` installs it anyway) and
engine 0.1.32 or newer (checked before anything is downloaded), and an NVIDIA
GPU: it has not been run on AMD cards (its prompt kernels for the Q4_K / Q5_K experts are NVIDIA-only), so with
`--backend hip` setup says so and asks before the download (#429; `--model UD-Q4_K_XL --yes` tries it). One GPU only: with `--gpus` it uses the first one and says so. No images (the vision encoder is not wired to
this file yet) and no experimental speed projection (not tested with it).
- It downloads the four shards below from the pinned revision `38bb39e` (resumable, like the other models), then
checks each one's size and SHA-256 against the table below; the check takes a few minutes once and is remembered
in the file's finish mark. A file with the wrong hash is deleted, so the next run downloads it again.
- It packs with `--compat-bf16` and never writes `experts.bin` (`--low-ram` does not apply).
- The RAM budget is the PC's RAM less 24 GB: `--resident-budget-gib 40` on 64 GB, at most all 71 GiB of experts on
96 GB or more. From a 64K context up, where the KV cache moves to RAM, its size comes out of the budget. Setup's
`--resident-budget-gib N` sets another one (a bigger one is kept, with a note on what it risks).
- It recommends an 8K context on a GPU under 14 GB (every GB of KV cache is a GB less of cached experts).
## The files
Four shards, 111,334,654,784 bytes (111.3 GB) together. Shard 1 holds only the metadata; layer 11's down projection is
@@ -24,7 +49,8 @@ in shard 2 and its gate/up in shard 3, which this engine handles (`native_expert
| `Qwen3.8-Flash-Next-UD-Q4_K_XL-00004-of-00004.gguf` | 12,087,983,520 | `753bda48b98ba4f1636134a90a967de1b2d3908a236c026e464777342e53510a` |
Keep the four files together in one folder under these names (the engine finds shards 2-4 from shard 1's name; a
missing shard is an error that names it). Check them before packing (`sha256sum -c`, or `Get-FileHash` on Windows).
missing shard is an error that names it). Setup checks them itself; by hand, check them before packing
(`sha256sum -c`, or `Get-FileHash` on Windows).
Hugging Face snapshot symlinks work if you pass the snapshot's file name, not the hash-named blob it points to.
## What it needs
@@ -35,7 +61,7 @@ Hugging Face snapshot symlinks work if you pass the snapshot's file name, not th
| RAM | 64 GB measured. The RAM budget (below) holds the most-used experts; the rest come from the SSD through the OS file cache. |
| GPU | 12 GB measured (RTX 5070): after the weights, the draft layer and the buffers, the expert cache held 1,280 of the 24,576 experts (3.7 GiB) at 4K context. |
## The pack
## The pack (by hand)
Build Strata (or install 0.1.31+), then, from the repository root, with gguf-py from the pinned llama.cpp
(setup installs it; `STRATA_GGUF_PY` can point to its `gguf-py` folder):
@@ -61,8 +87,8 @@ source/destination type, method, whether it is exact, the largest absolute error
264 F32 tensors (routers, injections, SSM gates) whose values are already BF16's are stored as BF16 exactly; the 195
Q8_0 projections above are rounded to BF16 (largest absolute error 0.0144, in `hc_ffn_up`); the PLE convolution (F32)
is narrowed to F16 when the engine loads it (largest error 3e-8). That is the compatibility Unsloth's file needs; it is
not the original BF16 checkpoint. The output has not yet been compared with llama.cpp on the same GGUF (argmax
agreement); treat quality as unmeasured.
not the original BF16 checkpoint. The output matches llama.cpp's on the same file:
[Quality](#quality-against-llamacpp-on-the-same-file).
## The MTP draft layer
@@ -107,7 +133,7 @@ budget take first.
**Choosing the RAM budget N:** your RAM minus 20-24 GB (the OS, the engine itself, the 126 MB router copy, and room for
the OS file cache that serves the rest). On 64 GB, 40. The engine clamps a budget larger than the RAM it finds free
(minus 4 GB) and says so. Everything above N comes from the SSD for every token, so N is the setting that matters
(minus 4 GB and a 256 MiB margin) and says so; before #403's fix such a clamped budget could then fail the start. Everything above N comes from the SSD for every token, so N is the setting that matters
most; a bigger budget was faster in every measurement (24 / 32 / 40 GiB). When the driver page-locks the whole budget
(24 GiB did on this PC, 32 and 40 did not), the GPU also computes a share of the misses over PCIe, as in the resident
low-RAM mode; `STRATA_RESIDENT_PIN=0` keeps the budget locked only (the CPU then computes every miss).
@@ -123,13 +149,61 @@ low-RAM mode; `STRATA_RESIDENT_PIN=0` keeps the budget locked only (the CPU then
| 40 GiB | **7-8.5 tok/s** (6 runs: 6.7-8.6, mean 7.9) | 1.1 GB read from the SSD per verify round, ~0.33 GB per token |
- The first answer comes about 60 s after the engine starts (mostly loading: the RAM budget is copied from the GGUF
files at start). A 30-token prompt then took 5-6 s. Longer prompts have not been measured and will be slow: the
prompt path dequantizes these formats to FP16 (fast prompt kernels for Q4_K/Q5_K/Q5_1 are future work).
files at start). A 30-token prompt then took 5-6 s.
- Long prompts (engine 0.1.32): a 16K prompt is read at **160 tokens/s** (15,873 tokens in 99 s; 0.1.31: 57 tokens/s,
273-282 s). Reading a prompt streams every expert once per 4K chunk, and the third of them outside the RAM budget
come from the SSD (~35 GB per chunk), so the prompt path now reads them with 32 threads and up to 128 blobs in
flight when the experts are read from the GGUF in place (`STRATA_STAGER_THREADS` / `STRATA_STAGER_RING` override
it; 4 and 16 before, still the default for every other model). Between the chunks, the 28.8 GB PLE table's rows
take ~5 s per chunk.
- An engine compiled with `-DSTRATA_MMQ_KQUANTS=ON` multiplies the Q4_K / Q5_K / Q5_1 experts of a prompt with
llama.cpp's MMQ kernels (as the other models' formats always are) instead of dequantizing them to FP16: 4x less GPU
time for those products, but on this PC the prompt waits for the SSD either way (29 s per 4K chunk with or
without). Off in the released engine: it loads every kernel at start, and these took 1-3 expert slots of VRAM from
every model.
- The speed varies from run to run (6.7-8.6 tok/s at 40 GiB for the same prompt), with what the OS file cache holds.
- For comparison, IQ3_S (all its experts in RAM) writes ~53 tok/s on the same PC ([the speed tables](DETAILS.md#speed-measured)).
- Where the time goes (`--stats` on the command line): at 40 GiB about 550-750 ms of each verify round (3.5 tokens) is
reading experts from the SSD, ~75 ms the CPU's expert kernels, ~13 ms the GPU.
## Quality: against llama.cpp on the same file
Strata and llama.cpp (the pinned commit `3cf0325`, a CPU build reading the GGUF memory-mapped) were given the same
token sequences, and at every position each wrote its 20 most likely next tokens with their log-probabilities: Strata
through its verify windows (`STRATA_LOGPOS` with `STRATA_LOGPOS_TOPK=20`), llama.cpp from `llama_get_logits_ith` over
one batch. Strata ran with the recommended settings (RAM budget 40 GiB, the MTP draft layer on, greedy,
`--adapt-every 100000` so the expert cache does not move during the run). Per position: whether the most likely token is the same (argmax
agreement), how many of the 5 / 10 most likely tokens both have, the KL divergence (llama.cpp's distribution against
Strata's, over llama.cpp's top 20 plus one bucket for the rest), and the perplexity of the sequence under each.
| Token set | Positions | Argmax agreement | Top-5 / top-10 overlap | KL | Perplexity Strata / llama.cpp |
| --- | ---: | ---: | ---: | ---: | ---: |
| Code: Strata's greedy answer to a coding prompt (an LRU cache in Python, 56-token prompt) | 400 | **99.0%** | 95.6% / 95.3% | 0.0008 | 1.067 / 1.070 |
| Thinking: Strata's greedy answer to a reasoning puzzle (with thinking, 55-token prompt) | 324 | **97.5%** | 96.2% / 96.4% | 0.0016 | 1.107 / 1.106 |
| After a 16K prompt: Strata's greedy continuation (256 tokens) of the 16,384 real-text tokens below | 256 | 90.2% | 88.1% / 89.3% | 0.039 | 1.619 / 1.623 |
| Real text after a 16K prompt: the last 510 tokens of 16,384 tokens of this repository's docs and code | 510 | 91.2% | 88.3% / 89.9% | 0.035 | 4.27 / 4.29 |
| The same 511 positions with only 512 tokens before them | 511 | 89.2% | 86.0% / 86.3% | 0.061 | 8.99 / 8.87 |
For comparison, [eddoursul/Strata](https://github.com/eddoursul/Strata) reported 98.2-99.3% (code), 95.6-95.8%
(thinking) and 97.7% (after a 16K prompt) for the same file against llama.cpp on an RTX 3090.
**Where they differ:** at near-ties. Grouped by llama.cpp's own gap between its two most likely tokens, Strata picks
the same token at every position of the code and thinking answers where that gap is 0.2 or more (695 of 695) and at
every position after the 16K prompt where it is 0.5 or more (144 of 144 in the continuation, 254 of 254 in the real
text; 50 of 54 and 117 of 121 between 0.2 and 0.5). Text after a long document has many more near-ties than a short
answer (58 of the continuation's 256 positions and 135 of the real text's 510 have a gap under 0.2, against 13 of the
code answer's 400), which is why its agreement is lower. It is not the long context itself: the same real-text
positions after only 512 tokens agree a little less (89.2%), and the perplexities match within 3% in every set. For
scale, Strata's own greedy run and its teacher-forced rerun of the continuation pick different tokens at 6% of the
positions (93.8% the same: other window sizes, other experts in VRAM), the same kind of near-tie flips. The 16K
prompts were read by Strata's batched prompt path (15,872 tokens), the rest through the verify windows. These numbers
are the FP16 prompt path's (the released engine's); with the MMQ prompt path (`-DSTRATA_MMQ_KQUANTS=ON`, above) the
continuation after the 16K prompt agrees at 92.2% (KL 0.029, three runs, identical).
To repeat it: Strata's side is `STRATA_LOGPOS=<file>` with `STRATA_LOGPOS_TOPK=20` (engine 0.1.32) on a serve
engine with `--short-read` covering the positions to compare; llama.cpp's side was a short program over
`llama_decode` / `llama_get_logits_ith` writing the same top 20 per position.
## Opt-ins and switches (environment variables)
| | |
@@ -137,7 +211,7 @@ low-RAM mode; `STRATA_RESIDENT_PIN=0` keeps the budget locked only (the CPU then
| `STRATA_LOOKAHEAD=0` | Turns off the routing-aware prefetch (on by default in this mode): while the CPU works on a layer, a thread applies the next layer's router to this layer's input and asks the OS to read the predicted experts' pages. Pages only, the answers are the same. About half of the SSD reads were predicted; mean +14% (6.9 -> 7.9 tok/s). `STRATA_LOOKAHEAD_K` sets the experts per token (default 10). |
| `STRATA_KQ256=1` | Multi-token AVX2 kernels for the Q4_K / Q5_1 / Q8_0 experts. Bit-exact with ggml's, but measured no faster, so off. |
| `STRATA_PARTIAL_PIN=1` | Registers the hottest part of the RAM budget (up to `STRATA_PARTIAL_PIN_GIB`, default 24) with the GPU driver, so the GPU computes a share of the misses over PCIe (`--pcie-frac`). Measured no faster on this PC, and it changes the numerics of those experts (GPU kernels instead of the CPU's), so off. |
| `STRATA_FETCH_THREADS=N` | Threads that read the experts from the GGUF (default 8; 16 was no faster). |
| `STRATA_FETCH_THREADS=N` | Threads that read the experts from the GGUF while it answers (default 8; 16 was no faster). The prompt path has its own: `STRATA_STAGER_THREADS` (default 32 here) and `STRATA_STAGER_RING` (128). |
## Scope and validation
@@ -145,10 +219,12 @@ low-RAM mode; `STRATA_RESIDENT_PIN=0` keeps the budget locked only (the CPU then
have kernels for; the engine checks every layer's formats at start and refuses an unsupported one by name.
- Tests: the packer's synthetic 4-shard and conversion tests (`.venv/bin/python -m unittest discover -s tools -p
test_iq_pack.py`); CTests `gguf_split_test`, `expert_layout_test`, `native_expert_parity_*` (the three real expert
format pairs against ggml-cpu, the Q5_1 min term, Q8_0 rows); the in-place mode against `experts.bin` on the Coder
format pairs against ggml-cpu, the Q5_1 min term, Q8_0 rows), `prefill_mmq_kquant_test` (with `-DSTRATA_MMQ_KQUANTS=ON`: the
prompt path's MMQ products for Q4_K / Q5_K / Q5_1 / Q8_0 against ggml's dequantized weights); the in-place mode against `experts.bin` on the Coder
(identical tokens and logits).
- Real runs: greedy answers to a coding prompt (correct) at every budget and setting above, identical across them. Not
yet: llama.cpp argmax agreement, long prompts, sampled decoding, long conversations. Please report what you see.
- Real runs: greedy answers to a coding prompt (correct) at every budget and setting above, identical across them;
the same tokens as llama.cpp at 97.5-99% of the positions of short greedy answers and 90-91% after a 16K prompt,
differing mostly at near-ties (above). Not yet: sampled decoding, long conversations. Please report what you see.
## Credits
+6
View File
@@ -96,6 +96,8 @@ inline const char* ggml_type_name(uint32_t t) {
return "IQ2_S";
case 23:
return "IQ4_XS";
case 24:
return "I8";
case 30:
return "BF16";
case 34:
@@ -204,6 +206,10 @@ inline bool block_geometry(uint32_t t, int& elems, int& bytes) {
elems = 256;
bytes = 56;
return true;
case 24: // I8: raw bytes (the FP8 PLE table of tools/ple_fp8_pack.py)
elems = 1;
bytes = 1;
return true;
case 42:
elems = 64;
bytes = 18;
+37 -1
View File
@@ -164,8 +164,44 @@ public:
return true;
}
// #342: drop the parked entries an outgoing conversation (its live tokens and checkpoint chain) supersedes:
// the same conversation a turn back, whose DEEPEST checkpoint the outgoing chain still holds, so all it adds
// is the tail the client rewrote (the reply as it was generated, before the next request re-rendered it) and
// checkpoints older than that one. A subagent's successive turns parked one such copy each, and make_room's
// oldest-first eviction then pushed the parent conversation out after `slots` turns. An entry without
// checkpoints, or whose deepest checkpoint the outgoing chain does not hold (another conversation that only
// shares the system prompt's root with it), is kept. Returns how many were dropped.
size_t drop_superseded(const std::vector<int32_t>& ids, const std::vector<ConversationImageKey>& images,
const std::vector<ConversationCheckpoint>& checkpoints, bool cvec) {
auto held = [&](const ConversationCheckpoint& c) {
if (c.ids == ids && c.imgs == images) return true;
for (const auto& k : checkpoints)
if (k.ids == c.ids && k.imgs == c.imgs) return true;
return false;
};
size_t dropped = 0;
for (size_t i = 0; i < entries_.size();) {
const auto& e = entries_[i];
const ConversationCheckpoint* deepest = nullptr;
for (const auto& c : e.checkpoints)
if (!deepest || c.ids.size() > deepest->ids.size()) deepest = &c;
if (e.cvec == cvec && deepest && !deepest->ids.empty() && held(*deepest)) {
bytes_ -= e.bytes();
entries_.erase(entries_.begin() + (std::ptrdiff_t) i);
++dropped;
continue;
}
++i;
}
superseded_ += dropped;
return dropped;
}
size_t superseded() const { return superseded_; }
bool put(SavedConversation&& image, size_t held = 0) {
const size_t n = image.bytes();
if (!enabled() || held > budget_ || n > budget_ - held) return false; // make_room's refusal, first
drop_superseded(image.live.ids, image.live.imgs, image.checkpoints, image.cvec);
if (!make_room(n, held)) return false;
entries_.push_back(std::move(image));
bytes_ += n;
@@ -173,7 +209,7 @@ public:
}
private:
size_t budget_ = 0, slots_ = 0, bytes_ = 0, evictions_ = 0;
size_t budget_ = 0, slots_ = 0, bytes_ = 0, evictions_ = 0, superseded_ = 0;
std::deque<SavedConversation> entries_; // least recently active first
ConversationKvReuse reuse_;
};
+11 -3
View File
@@ -49,15 +49,23 @@ namespace strata::core {
/// The longest penalty window the coupled drafter mirrors (serve's kPenaltyWindowCap).
inline constexpr int kCoupledHistCap = 4096;
/// STRATA_SPEC_COUPLED=1 (any value but empty or "0"): read once.
inline bool coupled_draft_env() {
static const bool on = [] {
inline bool& coupled_draft_state() {
static bool on = [] {
const char* e = std::getenv("STRATA_SPEC_COUPLED");
return e != nullptr && *e != '\0' && std::strcmp(e, "0") != 0;
}();
return on;
}
/// Whether coupled draft sampling is enabled (via STRATA_SPEC_COUPLED=1 or --coupled-draft CLI flag).
inline bool coupled_draft_env() {
return coupled_draft_state();
}
inline void set_coupled_draft(bool on) {
coupled_draft_state() = on;
}
/// The MTP cell of chain step j after a window at `p` with `a` drafts accepted (draft() runs step j there).
STRATA_COUPLED_HD inline int64_t coupled_draft_cell(int64_t p, int a, int j) { return p + a + j; }
+14
View File
@@ -38,11 +38,25 @@ std::string gpu_arch_problem(int ordinal);
// The GPU architectures this binary was compiled for ("gfx1100,gfx1201"); "" on CUDA builds.
const char* compiled_gpu_archs();
// How many devices the runtime enumerates, numbered as HIP_VISIBLE_DEVICES / CUDA_VISIBLE_DEVICES number them; 0 when
// there is none (or no usable runtime). `device_info` throws on a card this binary cannot run, so a caller that LISTS
// every card - the ones it has no code for included (`strata-device --list-devices`) - counts them with this first.
int device_count();
// One line per device for `strata-device --list-devices`, without the arch check that `device_info` applies:
// "arch gfx1201, 15.9 GiB" (HIP) or "compute capability 12.0, 11.9 GiB" (CUDA), plus the device's name. false when
// the runtime cannot describe it.
bool device_summary(int ordinal, std::string& name, std::string& detail);
// Throws when there is no CUDA device. The engine targets sm_120 specifically and must say so rather than
// run slowly on something else: `CMakeLists.txt` already refuses to COMPILE for another architecture, and
// this is the matching check at run time (a binary can be carried to a different machine).
DeviceInfo device_info(int ordinal = 0);
/// "" when this build has device code for the current device, else CUDA's error: a build for other GPUs would
/// otherwise fail at its first kernel launch, with nothing that names the cause.
std::string device_code_error();
class CudaError : public std::runtime_error {
public:
CudaError(const std::string& what, int code) : std::runtime_error(what), code_(code) {}
+36
View File
@@ -0,0 +1,36 @@
// strata/core/emulate.hpp - STRATA_EMULATE_CC: another GPU generation's code paths, run on this card (tests only).
//
// A build whose device code is PTX for an older architecture (CMAKE_CUDA_ARCHITECTURES=86-virtual) runs on a newer
// card through the driver's JIT, and every kernel then sees __CUDA_ARCH__ = 860. What the host decides from the
// device must agree with that, or it picks paths and shared-memory sizes the device code was not built for:
// STRATA_EMULATE_CC=75|80|86|89 makes each such query (compute capability, shared memory per block) answer as that
// card would. Speed under it says nothing about the real card; results do - the kernels are the real card's.
#pragma once
#include <cstdlib>
namespace strata {
/// the emulated compute capability as 10 * major + minor, or 0 (off)
inline int emulated_cc() {
static const int cc = [] {
const char* e = std::getenv("STRATA_EMULATE_CC");
const int v = e ? std::atoi(e) : 0;
return v >= 75 && v < 120 ? v : 0;
}();
return cc;
}
inline int cc_major_of(int real_major) { return emulated_cc() ? emulated_cc() / 10 : real_major; }
inline int cc_minor_of(int real_minor) { return emulated_cc() ? emulated_cc() % 10 : real_minor; }
/// cudaDevAttrMaxSharedMemoryPerBlockOptin as the emulated card reports it: 64 KB on Turing, 163 KB on the A100,
/// 99 KB on the other Ampere and Ada cards
inline int smem_optin_of(int real) {
switch (emulated_cc()) {
case 0: return real;
case 75: return 65536;
case 80: return 166912;
default: return real < 101376 ? real : 101376;
}
}
} // namespace strata
+18
View File
@@ -55,6 +55,24 @@ inline constexpr int32_t kNotResident = -1;
bool read_expert_profile(const std::string& path, int64_t n_layers, int64_t n_expert,
std::vector<std::pair<int32_t, int32_t>>& ranked, int64_t& slots, std::string& err);
/// #477 (--expert-profile-save): what the adaptive tier learned, as a profile ranking EVERY (layer, expert) pair -
/// the experts resident in VRAM now first (where the swaps left the cache), then the rest; within each, by `heat`
/// (the routing the adaptive tier counted since the start, descending), then by `prior` (the profile the engine
/// started from: an expert this run never routed keeps its old place), then by index. A start from it begins
/// where this one ended; one with more slots adds the hottest of the rest, one with fewer keeps the hottest.
/// `resident` and `heat`: n_layers x n_expert entries (`heat` may be empty: no counts, the prior decides).
std::vector<std::pair<int32_t, int32_t>> rank_learned_profile(int64_t n_layers, int64_t n_expert,
const std::vector<uint8_t>& resident,
const std::vector<double>& heat,
const std::vector<std::pair<int32_t, int32_t>>& prior);
/// #477: writes `ranked` in tools/make_profile.py's format, byte for byte: `STRP`, `1, n_layers, n_expert,
/// n_ranked, n_ranked` (uint32), the pairs (uint16 layer, uint16 expert), then the n_layers x n_expert int32 table
/// of each pair's rank (-1: not ranked). Atomically: a `<path>.tmp` beside it, renamed over `path` once complete,
/// so a reader (the next start) never sees half a file. False with `err` when it could not be written.
bool write_expert_profile(const std::string& path, int64_t n_layers, int64_t n_expert,
const std::vector<std::pair<int32_t, int32_t>>& ranked, std::string& err);
class ExpertCache {
public:
ExpertCache() = default;
+4
View File
@@ -421,6 +421,10 @@ public:
/// CS-T, `budget_bytes` > 0 (`--resident-budget-gib`): only as many of those experts as fit `budget_bytes`, taken
/// in `rank` order (the expert profile: the hottest after the GPU cache's), are copied; the rest stay on the
/// mapped files (the SSD tier). No lend region then (a lent slot's expert is read from the files).
/// #467: `budget_bytes` = `kResidentWhatFits` is that path sized by the RAM alone (available minus the headroom
/// and the #403 margin) - the soft --resident-experts mode's second try when the whole complement does not fit;
/// false when not even one expert fits. On Windows the mapped experts leave the working set before any reading.
static constexpr uint64_t kResidentWhatFits = ~0ull;
bool pin_cache_complement(
const ExpertCache& cache, std::string& err, bool pin = true,
const std::vector<std::pair<int32_t, int32_t>>& additional_gpu_pairs = {}, int64_t lend_from_slot = -1,
+6
View File
@@ -217,6 +217,9 @@ struct QsaState {
/// Uses k_q/k_scale + v_q4. Mode 0 only (no KV streaming, no ring); under this setting the MTP drafter's
/// ring state stays plain INT8, so the block movers never see the hybrid layout.
bool kv_hybrid = false;
/// K and V go through kv_q4.hpp's Walsh-Hadamard rotation before they are stored, the queries too, the output
/// back: always for Q4_0, for INT8 by qsa_set_kv_int8_rotate (spreads outlier channels over the scale groups)
bool kv_rot = false;
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;
@@ -237,6 +240,7 @@ struct QsaState {
int32_t* idx_block_pos = nullptr;
float* cos_tab = nullptr; ///< (max_cells, n_rot/2), built on the HOST in float64
bool owns_rope = false; ///< built the table above (not borrowed with share_rope): it releases it
float* sin_tab = nullptr;
/// THE PER-TOKEN COUNTS, IN DEVICE MEMORY - the whole reason this layer can be a graph. `qsa_step_fill`
@@ -278,6 +282,8 @@ uint64_t qsa_kv_host_bytes();
/// Plan v0.3 P7: store K/V as INT8 with FP16 scales per 64 values (half the VRAM of FP16). Set before sizing and
/// initializing the session; default off until gate G-C accepts it.
void qsa_set_kv_int8(bool enabled);
/// INT8 K/V through the Hadamard rotation (off by default: STRATA_KV_ROT=1)
void qsa_set_kv_int8_rotate(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);
+4
View File
@@ -25,6 +25,10 @@ public:
/// supported type, 2-D), read from the GGUF headers only - so the canonical arena can skip them.
static bool served_names(const std::vector<std::string>& shards, bool include_ple_key,
std::set<std::string>& out, std::string& err);
/// #326: a native pack whose `blk.1.ple_key.weight` row is unquantized (iq_pack --compat-bf16 of a GGUF key
/// the native kernel also reads, e.g. OrcaRouter's IQ3_XXS) serves the PLE from that row, so it is taken out
/// of `skip` and `load` does not upload the GGUF key over it. A quantized row leaves `skip` unchanged.
static bool keep_unquantized_ple_key(const std::string& pack_dir, std::set<std::string>& skip, std::string& err);
uint64_t weight_bytes() const { return bytes_; }
size_t tensor_count() const { return weights_.size(); }
+3
View File
@@ -99,6 +99,9 @@ uint64_t session_bytes(const ModelGeometry& g, int64_t max_cells, int64_t k, int
/// Carves `base` (DEVICE memory) into `s`. Returns the bytes used. Same range convention as `session_bytes`.
uint64_t session_init(const ModelGeometry& g, int64_t max_cells, int64_t k, void* base, SessionState& s,
int64_t layer_lo = 0, int64_t layer_hi = -1);
/// Before the memory `session_init` carved is freed: forgets what points into it from outside the session (the
/// rope kernels' registered angle table, #280), so a later session never rotates by freed memory.
void session_release(SessionState& s);
/// Zeroes every layer's state - the residual to `R_init`, everything else to zero, so a fresh sequence starts
/// from the reference's own `zeros()`.
void session_zero(SessionState& s, const ModelGeometry& g, const float* R_init, void* stream);
+15 -1
View File
@@ -74,6 +74,9 @@ 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);
/// Diagnostics: row `t` of the last window's head logits (n_vocab floats) to the host. Valid after run().
bool copy_logits(int t, float* host) const;
int64_t vocab() const { return next_ ? next_->vocab() : n_vocab_; }
/// 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.
@@ -116,6 +119,14 @@ public:
/// 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);
/// commit() returns without waiting for its graph (a single-GPU session sets it): the next window follows it on
/// the same stream and the drafter reads nothing it writes, so it overlaps the draft. Whoever reads or writes
/// the session from another stream or the host afterwards (a new request, a checkpoint, a snapshot, the prompt
/// path, the end of a run) calls wait_commit() first. STRATA_COMMIT_SYNC=1 keeps the wait.
static void set_commit_async(bool on);
/// Waits for the last commit graph when commit() did not (an event recorded after it, not the whole device);
/// false with `err` when it failed. Free when nothing is pending.
bool wait_commit(std::string& err);
/// Measurement hook (STRATA_LOGPOS): after run(), write one line per row t of the last window's head -
/// "pos target logprob top top_logprob hit extra_logprob target_logprob_without_extra" - where row t is the
@@ -173,7 +184,8 @@ private:
bool ple_stage() const { return lb_ <= 1 && 1 < le_; } ///< holds layer 1, where the PLE block runs
bool capture_commit(std::string& err);
bool record_window(int T, cudaStream_t cs, std::string& err);
static constexpr int kProfPer = 32; // stamps per layer
static constexpr int kProfPer = 33; // stamps per layer (32 left the hc-read second
// half's up-stamp at slot 32 = the next layer's slot 0: D8)
bool prof_on_ = false;
unsigned long long* prof_ = nullptr; // device: n_layers * kProfPer + 4 stamps
std::vector<unsigned long long> prof_h_;
@@ -208,6 +220,8 @@ private:
uint32_t* h_flag_ = nullptr; uint32_t* m_flag_ = nullptr;
uint32_t* h_flagA_ = nullptr; uint32_t* m_flagA_ = nullptr; // the GPU plan is in place
uint32_t* h_flagB_ = nullptr; uint32_t* m_flagB_ = nullptr; // the PCIe share's DMA copies have landed
cudaEvent_t commit_done_ = nullptr; // recorded after an async commit (set_commit_async); see wait_commit
bool commit_pending_ = false;
cudaStream_t copy_ = nullptr; // the copy engine's stream (DMA of missed experts)
struct FlagSet { uint32_t* flag; uint32_t value; };
FlagSet flag_sets_[2 * 64 * 2] = {}; // host-function arguments, one per (layer, group)
+6
View File
@@ -113,6 +113,12 @@ public:
static bool pool_bytes(const std::string& pack_dir, uint64_t& out, std::string& err,
const std::set<std::string>* skip = nullptr);
/// The `code_bits` field of one row of `<pack_dir>/index.txt`, readable WITHOUT loading anything (0 = the
/// pack stores the tensor unquantized, e.g. a --compat-bf16 key; -1 = no such row). #326: the loader has
/// to know this before it builds the skip set.
static bool index_code_bits(const std::string& pack_dir, const std::string& name, int& code_bits,
std::string& err);
/// Load every tensor in `<pack_dir>/index.txt` into `arena_base`.
///
/// `arena_bytes` MUST be at least `pool_bytes`; the loader checks rather than trusting the caller,
+10
View File
@@ -1,5 +1,14 @@
#pragma once
// Included only by STRATA_ENABLE_HIP builds. CUDA builds use NVIDIA headers.
#if defined(_WIN32)
// hip_runtime.h pulls in <windows.h> on Windows: keep its min/max macros and the rarely used APIs out of the engine.
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#endif
#include <hip/hip_runtime.h>
// Do not let HIP's legacy macro corrupt libstdc++ attribute names.
#ifdef __noinline__
@@ -9,6 +18,7 @@
#define cudaDevAttrMultiProcessorCount hipDeviceAttributeMultiprocessorCount
#define cudaDevAttrClockRate hipDeviceAttributeClockRate
#define cudaDevAttrComputeCapabilityMajor hipDeviceAttributeComputeCapabilityMajor
#define cudaDevAttrComputeCapabilityMinor hipDeviceAttributeComputeCapabilityMinor
#define cudaDeviceGetAttribute hipDeviceGetAttribute
#define cudaDeviceProp hipDeviceProp_t
#define cudaDeviceSynchronize hipDeviceSynchronize
+5 -1
View File
@@ -1,6 +1,6 @@
#pragma once
// CUDA device intrinsics used by Strata kernels that HIP does not provide on RDNA3 / RDNA4 (wave32).
// CUDA device intrinsics used by Strata kernels that HIP does not provide on RDNA2 / RDNA3 / RDNA4 (wave32).
// This header is included only from the HIP cuda_runtime compatibility shim.
#if defined(__HIPCC__)
@@ -21,6 +21,10 @@ __device__ __forceinline__ int dp4a(int a, int b, int c) {
// both packed operands signed to preserve CUDA __dp4a semantics; keep the
// portable path for other HIP compilers/targets.
return __builtin_amdgcn_sudot4(true, a, true, b, c, false);
#elif (defined(__gfx1030__) || defined(__gfx1031__) || defined(__gfx1032__)) && __has_builtin(__builtin_amdgcn_sdot4)
// RDNA2 has no sudot4 (that is gfx11+), but it has the plain signed v_dot4_i32_i8 (dot1-insts): the same
// signed x signed byte products accumulated modulo 2^32, no clamp.
return __builtin_amdgcn_sdot4(a, b, c, false);
#else
const uint32_t ua = static_cast<uint32_t>(a);
const uint32_t ub = static_cast<uint32_t>(b);
+30 -2
View File
@@ -63,13 +63,32 @@ struct ExpertJobMulti {
const void* nact[MAXT] = {};
};
/// How worker threads are allocated across physical/logical CPU cores (#272). `All` is the layout the pool has
/// always used and the default; the hybrid-aware ones are opt-in (--pool-affinity auto|p-cores).
enum class PoolAffinity {
Auto, ///< Hybrid: prioritize physical P-cores, then SMT, then E-cores (defaults to P-core count)
PCores, ///< Restrict workers strictly to Performance cores and their SMT siblings
All, ///< The default: one worker per physical core in the OS's order, without hybrid distinction
};
struct CpuTopology {
bool is_hybrid = false;
int p_cores = 0; ///< Physical performance cores
int p_threads = 0; ///< Total logical threads on performance cores
int e_cores = 0; ///< Efficient cores
std::vector<int> worker_cores; ///< Ordered logical core IDs for workers (excluding host if skip_first)
int host_core = -1; ///< Logical core reserved for host thread
};
CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity = PoolAffinity::All);
/// One logical processor per PHYSICAL core, so a worker is never scheduled onto an SMT sibling of another
/// worker. On the 6-core/12-thread machine this project measures on, `hardware_concurrency()/2` workers on
/// logical processors 0..5 would put every worker on a sibling pair and halve the useful bandwidth - which is
/// exactly the kind of error that shows up as "the CPU path is slower than the model says" with no clue why.
///
/// `skip_first` drops the first core, which P2.S3 reserves for the host loop.
std::vector<int> physical_cores(bool skip_first);
std::vector<int> physical_cores(bool skip_first, PoolAffinity affinity = PoolAffinity::All);
/// **THE RESERVATION IS A FICTION UNLESS THE HOST IS ACTUALLY PUT THERE.**
///
@@ -101,7 +120,8 @@ public:
/// With `host_works`, `run()` claims jobs itself instead of spinning on `done_`, and the pool is six
/// threads on six cores. `false` is the A/B arm and exists so the change is measurable rather than
/// asserted - the counter it moves is `pool phases ... drain`, which is host-side and needs no profiler.
explicit ExpertPool(int n_workers = 0, bool pin = true, bool host_works = true);
explicit ExpertPool(int n_workers = 0, bool pin = true, bool host_works = true,
PoolAffinity affinity = PoolAffinity::All);
/// The watchdog's view of the pool (issue #31): the batch, the counters, every thread's state.
void diag(std::FILE* f) const;
~ExpertPool();
@@ -113,6 +133,12 @@ public:
/// is on" is only true if the engine says which adaptation it took.
bool host_works() const { return host_works_; }
bool is_hybrid() const { return topo_.is_hybrid; }
int p_cores() const { return topo_.p_cores; }
int p_threads() const { return topo_.p_threads; }
int e_cores() const { return topo_.e_cores; }
PoolAffinity affinity() const { return affinity_; }
/// Publish `n` jobs, then block until every one has been claimed AND every worker has parked.
/// `jobs` must outlive the call (it does, and the workers never touch it afterwards).
void run(ExpertJob* jobs, int n);
@@ -239,6 +265,8 @@ private:
};
const NativeFmt* nfmt_ = nullptr;
std::vector<SplitBufMulti> split_multi_;
PoolAffinity affinity_ = PoolAffinity::All;
CpuTopology topo_;
};
} // namespace strata::kernels::cpu
+13
View File
@@ -49,4 +49,17 @@ constexpr int kFusedGrMaxT = 8;
void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, void* stream,
unsigned long long* stamp_buf = nullptr, int stamp_i0 = 0);
/// The multi read's variants (#315; not main's opt-in STRATA_GR_V3 read, which sums in another order), all computing
/// every output with the plain read's operations in its order, so bitwise the plain read's and the single-token
/// read's: plain (0.1.31's default: the norm one block per token, the down projection on 41 blocks), split (the norm
/// one block per token and stream, then the plain down projection) and staged (split's norm, and the down
/// projection's activations staged ahead by cp.async, two half-stream tiles in flight, bank-conflict-free).
/// `fused_gr_check` runs all of them and the single-token read on the current card with random weights and inputs
/// (1..8 tokens, with and without the pending write) and from then on uses there the newest one that agrees with the
/// plain read bit for bit; STRATA_HC_SPLIT=0 keeps the plain read, =1 stops at split. It runs once per card
/// (Verifier::init calls it) and prints which one runs. On a card it has not checked, `fused_gr_variant` is the
/// plain read unless STRATA_HC_SPLIT=1 or 2 names a variant.
void fused_gr_check();
int fused_gr_variant();
} // namespace strata::kernels
+2
View File
@@ -14,6 +14,8 @@ namespace strata::kernels {
/// ggml type ids handled here.
bool iq_supported(int ggml_type) noexcept;
/// The token-embedding types iq_embed_rows and iq_dequant_f32 read: the i-quants above and BF16 (30).
bool embed_type_supported(int ggml_type) noexcept;
/// Bytes of one row of `n` values of `ggml_type` (n a multiple of the type's block).
size_t iq_row_bytes(int ggml_type, int64_t n) noexcept;
+28
View File
@@ -16,18 +16,46 @@
#include <cstdint>
#include "strata/kernels/rope_scaling.hpp"
namespace strata::kernels {
/// The table of the CURRENT device (a layer split sets one per device; null = the identity).
void mrope_table_set(const int32_t* device_table);
const int32_t* mrope_table();
/// #280, opt-in (STRATA_ROPE_TABLE=1): the rotation angles, 32 pairs of a 64-wide rotary slice, from the session's
/// float64 table (build_rope_table, [max_pos][32] cos and sin in VRAM, the rope scaling inside) of the CURRENT
/// device. With it the rope kernels rotate by the table's exact angles; without it they compute pos * powf(...) with cosf/sinf, which under
/// --use_fast_math is 0.0014 rad off at 32K and ~0.02 at 262K, and the prompt path (precise libm) and decode
/// (fast-math) disagree with each other.
struct RopeTab {
const float* cos = nullptr; ///< null: no table, the kernels compute the angle as they always did
const float* sin = nullptr;
int max_pos = 0;
};
/// Registers the table the session just built with `scaling` (the latest one of the device wins).
void rope_table_set(const float* cos_tab, const float* sin_tab, int max_pos, const RopeScaling& scaling);
/// Forgets the table `cos_tab` (on every device) - before the memory that holds it is freed (session_release).
void rope_table_release(const float* cos_tab);
/// The registered table when STRATA_ROPE_TABLE=1 and it was built with `scaling`; otherwise none (the default: the
/// kernels compute the angle exactly as before).
RopeTab rope_table_for(const RopeScaling& scaling);
#if defined(__CUDACC__) || defined(__HIPCC__)
/// ggml rope_multi, is_imrope, sections {11, 11, 10, 0}: sector = pair % 32; sector % 3 == 1 -> h (sector < 33),
/// == 2 -> w (sector < 30), == 0 -> t (sector < 33). For pairs 0..31 all three bounds hold, so it is pair % 3.
__device__ __forceinline__ int mrope_pos(const int32_t* tab, int pos, int pair) {
return tab ? __ldg(tab + (size_t) pos * 3 + pair % 3) : pos;
}
/// cos and sin of rotary position p, pair `pair` (0..31) from the table (scaling and magnitude included); false
/// when there is no table or p lies past it - then the caller computes the angle as it always did
__device__ __forceinline__ bool rope_tab_cs(const RopeTab& t, int p, int pair, float& c, float& s) {
if (t.cos == nullptr || p < 0 || p >= t.max_pos) return false;
c = __ldg(t.cos + (size_t) p * 32 + pair);
s = __ldg(t.sin + (size_t) p * 32 + pair);
return true;
}
#endif
} // namespace strata::kernels
+15 -1
View File
@@ -44,7 +44,9 @@ inline constexpr float NG_RMS_EPS = 1e-6f;
// The table: [160, 320001536] IQ4_NL. ne0 = 160 is the FAST axis, so one row is 160 contiguous elements =
// 5 blocks of 32 at 18 bytes = 90 bytes. The head-slowest flatten then makes 16 rows exactly n_embd = 2560.
inline constexpr uint64_t PLE_TABLE_ROWS = 320001536ull;
inline constexpr int PLE_ROW_BYTES = (PLE_HEAD_DIM / 32) * 18; // 90
inline constexpr int PLE_ROW_BYTES = (PLE_HEAD_DIM / 32) * 18; // 90: an IQ4_NL row
inline constexpr int PLE_ROW_BYTES_FP8 = PLE_HEAD_DIM; // 160: an F8_E4M3 row, one byte a value
inline constexpr int PLE_ROW_BYTES_MAX = PLE_ROW_BYTES_FP8;
/// The artifact's own hash constants, transcribed from `docs/gguf-dump-shard1.txt`:
///
@@ -96,6 +98,11 @@ int iq4nl_code(int code);
/// a perfectly plausible embedding of the wrong 160 values.
void iq4nl_dequant_row(const uint8_t* row, float* out160);
/// One FP8 row -> 160 floats: each byte an E4M3 value (the "fn" variant: no infinities, 0x7F/0xFF are NaN), times the
/// table's one scale. This is the table as Qwen3.8-Flash-Next ships it (`...ngram_embedding.shard_k`, F8_E4M3, and
/// `weight_scale`), kept byte for byte by tools/ple_fp8_pack.py; IQ4_NL is 8% off it per row.
void fp8_e4m3_dequant_row(const uint8_t* row, float scale, float* out160);
/// How the table's rows are read (plan v0.3 P2). `Direct` is the default: unbuffered 4 KiB reads from the SSD,
/// so the table never occupies RAM or the OS file cache. `Mmap` is the earlier memory-mapped path, kept as the
/// A/B arm; it returns the same bytes.
@@ -113,6 +120,11 @@ struct PleIoOptions {
/// Mmap mode only (`--ple-io ram`): lock the whole mapped table in RAM at open, so no SSD read ever sits on
/// the prompt or token path. Needs RAM for the full table. POSIX only (mlock); `locked()` reports the outcome.
bool lock = false;
/// Direct mode with the I/O worker only: keep the SSD awake while rows are asked for - one page of the table
/// after this long without a read (0 = off), until `keepalive_window_s` after the last request for rows
/// (see PleReader::set_keepalive).
double keepalive_ms = 0;
double keepalive_window_s = 60;
};
/// The PLE table. Held by pointer-to-impl so this header does not drag `<windows.h>` into every
@@ -150,6 +162,8 @@ public:
/// True when `PleIoOptions::lock` was asked for and mlock succeeded (false: pages only pre-touched).
bool locked() const;
uint64_t rows() const;
/// "IQ4_NL" or "F8_E4M3" (a GGUF from tools/ple_fp8_pack.py: type I8, strata.ple.format = f8_e4m3).
const char* format() const;
/// 16 row indices -> 2560 floats. The gathered rows are flattened HEAD-SLOWEST: row h's 160 values
/// occupy `out[h*160, (h+1)*160)`, which is what `ggml_get_rows` does and what makes the result a plain
+13 -2
View File
@@ -11,7 +11,8 @@
// a chunk it may already hold a block completed later, so it is not read).
// qsa_block_topk : one block per query; a 4-pass radix select over the query's n_bid + 1 blocks, each
// weighted by its cell count, then the cells emitted in ascending order with ties to the
// lowest index - the same selection as topk_kernel, over a quarter of the elements.
// lowest index - the same selection as topk_kernel, over a quarter of the elements. On sm_90+
// a call of up to 16 queries runs it on a cluster of 8 CTAs per query: the same ids.
//
// Queries carry their own step record (pos, n_kv, n_bid, width) as everywhere else in QSA.
#pragma once
@@ -30,12 +31,22 @@ void qsa_block_scores(const float* pooled, const float* dead, const float* q_idx
/// The same scores on tensor cores (3xTF32, FP32-level accuracy but another summation order: not bitwise; the tail
/// block n_bid is the warp kernel's arithmetic). For the prompt path; false (nothing launched) on another geometry.
/// On AMD it is the gfx12 (RDNA4) WMMA kernel (a three-way bf16 split, six products); false on any other AMD target.
bool qsa_block_scores_tc(const float* pooled, const float* dead, const float* q_idx, const int32_t* steps, int64_t nq,
int64_t max_blocks, const QsaShapes& s, float* scores, void* stream, int64_t active_blocks);
/// ids [nq, cap] (cells, ascending); `cap` >= the largest selection width.
void qsa_block_topk(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap,
const QsaShapes& s, int32_t* ids, void* stream);
const QsaShapes& s, int32_t* ids, void* stream,
int64_t active_blocks = -1); ///< > 0: no query of the call has more than this many blocks (n_bid + 1;
///< the same contract as qsa_block_scores's). The register kernel is
///< chosen by this, not by the capacity max_blocks (a long
///< --max-context otherwise sends every short prompt to the slow one)
/// The same ids on a thread-block cluster of 8 CTAs per query (sm_90+, CUDA; S19). qsa_block_topk takes it for calls
/// of up to 16 queries unless STRATA_QSA_CLUSTER=0. False (nothing launched) where it cannot run: HIP, a card or a
/// build below sm_90, or a capacity whose keys do not fit one cluster's shared memory. Capturable.
bool qsa_block_topk_cluster(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap,
const QsaShapes& s, int32_t* ids, void* stream);
/// The original kernel (keys read from memory on every radix pass), for tests: the same ids.
void qsa_block_topk_ref(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap,
const QsaShapes& s, int32_t* ids, void* stream);
+6
View File
@@ -17,4 +17,10 @@ namespace strata::kernels {
void router_top10(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights,
void* stream);
/// Tests (S6, HIP only): one router kernel by name - 0 the portable kernel, 1 the AMD fast kernel (the default there
/// for 64 < n_expert <= 512, k <= 32; bitwise the same results), 2 the fast kernel forced onto its serial-sum path.
/// False (nothing launched) on CUDA builds or outside that geometry.
bool router_top10_variant(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights,
void* stream, int variant);
} // namespace strata::kernels
+5
View File
@@ -42,6 +42,11 @@ struct SamplerParams {
void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* history, int history_len,
const SamplerParams& p, int* out, void* stream);
// The greedy pick (no penalties) on a thread-block cluster of 8 CTAs per row (sm_90+, CUDA; S19): the same token as
// sample_tokens' one-block argmax, which takes it unless STRATA_ARGMAX_MULTI=0. False (nothing launched) where it
// cannot run: HIP, a card or a build below sm_90. Device pointers; capturable.
bool sample_greedy_cluster(const float* logits, int n_tokens, int n_vocab, int* out, void* stream);
// ---- COUPLED DRAFT SAMPLING (include/strata/core/coupled_draft.hpp, STRATA_SPEC_COUPLED=1). Device pointers
// throughout; every per-request / per-round value comes from device memory, so the calls can be captured.
//
+20 -6
View File
@@ -1,6 +1,6 @@
// include/strata/ngram/ple_reader.hpp - plan v0.3 P2: the n-gram table read straight from the SSD.
//
// The table is 320,001,536 rows of 90 bytes (26.8 GiB) and is never held in RAM: every row comes from an
// The table is 320,001,536 rows of 90 bytes (IQ4_NL, 26.8 GiB) or 160 (FP8 E4M3, 51.2 GB) and is never held in RAM: every row comes from an
// unbuffered 4 KiB read (platform::DirectFile). A token needs 16 rows on 16 different pages, and all 16 depend
// on the token itself, so the only time to hide them is the embedding plus layer 0. Hence the split API:
//
@@ -24,7 +24,7 @@
namespace strata::ngram {
inline constexpr uint32_t ROW_BYTES = 90;
inline constexpr uint32_t ROW_BYTES = 90; ///< an IQ4_NL row; `open` takes the table's own size
inline constexpr uint32_t PAGE = 4096;
struct ReaderStats {
@@ -37,6 +37,8 @@ struct ReaderStats {
double submit_us = 0; ///< time spent inside the read submission call (non-zero = it blocks)
double read_us_sum = 0; ///< sum of per-read latencies (issue to completion)
uint64_t late_injected = 0; ///< reads delayed by fault injection
uint64_t keepalive_reads = 0; ///< pages read only to keep the SSD awake (not in `reads`, `bytes` or latencies)
double keepalive_us_max = 0; ///< the slowest of them
std::vector<float> read_us; ///< last <= 65,536 read latencies, for percentiles
double percentile(double q) const;
};
@@ -56,24 +58,36 @@ public:
/// validated GGUF parse (PleTable::open checks the table exactly fills the file from there).
/// `io_thread` (default): a worker thread submits and reaps reads, so `issue` costs the caller no ReadFile
/// calls. false: the caller's thread does it (A/B arm).
/// `row_bytes`: one row's size in the file (90 IQ4_NL, 160 FP8), at most one page.
bool open(const std::string& path, uint64_t table_offset, uint64_t n_rows, uint32_t max_inflight,
uint64_t cache_rows, std::string& err, bool io_thread = true);
uint64_t cache_rows, std::string& err, bool io_thread = true, uint32_t row_bytes = ROW_BYTES);
uint32_t row_bytes() const;
void close();
bool is_open() const;
/// Start fetching `n` rows; row i's 90 raw bytes land at `out_raw + 90 * i`. `out_raw` must stay valid
/// until `collect` returns. Out-of-range rows produce 90 zero bytes (the mmap path's behaviour).
/// Start fetching `n` rows; row i's raw bytes land at `out_raw + row_bytes * i`. `out_raw` must stay valid
/// until `collect` returns. Out-of-range rows produce zero bytes (the mmap path's behaviour).
Ticket issue(const uint32_t* rows, size_t n, uint8_t* out_raw);
/// Block until every row of the ticket is in `out_raw`. Returns false on an I/O error (message in `err`).
bool collect(Ticket t, std::string& err);
/// Keep the SSD awake while the table is in use (io_thread mode only; call after `open`): when no read has
/// gone out for `period_ms`, the worker reads one page of the table, until `window_s` after the last `issue`.
/// Some SSDs drop into a power state after ~250 ms without a command and stall the next reads 50-150 ms
/// (a WD_BLACK SN7100 on Windows 11; the NVMe idle timeouts of the power plan did not change it), which in
/// decode happens after a few rounds whose rows all came from the row cache. 0 turns it off (the reader's
/// default; generate turns it on, see STRATA_SSD_KEEPALIVE). A keep-alive read that fails turns it off.
void set_keepalive(double period_ms, double window_s);
/// Fault injection for tests and for the plan's P2 exit check: every read completes no earlier than
/// `delay_us` after it was issued. 0 disables.
void set_injected_delay_us(double delay_us);
/// Read with no ticket in flight (the worker updates these while reads are outstanding).
/// Read with no ticket in flight (the worker updates these while reads are outstanding; with the keep-alive
/// on it may also be counting a keep-alive read - `snapshot` takes a consistent copy).
const ReaderStats& stats() const;
ReaderStats snapshot() const;
void reset_stats();
uint64_t cache_capacity() const;
uint64_t cache_size() const;
+10 -7
View File
@@ -14,21 +14,24 @@ namespace strata::prefill {
// ---- hyper-connection (n_embd 2560, hc 4, hc_lr 320)
/// xn[t, c*2560 + d] = R[t,c,d] * rsqrt(mean_d R[t,c,:]^2 + eps) * w_norm[c*2560 + d]; also its BF16 image.
void gr_norm(const float* R, const float* w_norm, float eps, float* xn, uint16_t* xn16, int64_t T, void* stream);
/// `xn16_lo` (null: none) takes bf16(x - xn16): W.xn16 + W.xn16_lo is the product with ~16 mantissa bits of x.
void gr_norm(const float* R, const float* w_norm, float eps, float* xn, uint16_t* xn16, int64_t T, void* stream,
uint16_t* xn16_lo = nullptr);
/// F-1: gr_norm without its FP32 output: the row scales rs[t*4 + c] and the BF16 image; gr_mix_r then reads R.
void gr_norm_rs(const float* R, const float* w_norm, float eps, float* rs, uint16_t* xn16, int64_t T, void* stream);
void gr_norm_rs(const float* R, const float* w_norm, float eps, float* rs, uint16_t* xn16, int64_t T, void* stream,
uint16_t* xn16_lo = nullptr);
/// gr_mix with xn recomputed from R, rs and w_norm exactly as gr_norm computes it (the same bits).
void gr_mix_r(const float* R, const float* rs, const float* w_norm, const float* gated, float* mixed, uint16_t* mixed16,
int64_t T, void* stream, uint16_t* mixed_h = nullptr);
int64_t T, void* stream, uint16_t* mixed_h = nullptr, uint16_t* mixed16_lo = nullptr);
/// F-2: gr_write, then gr_norm_rs of the next half (its norm weights) over the rows just written - the same bits as
/// the two calls, without reading R back.
void gr_write_norm_rs(float* R, const float* bo, const float* inj, int64_t inj_ld, const float* w_norm_next, float eps,
float* rs, uint16_t* xn16, int64_t T, void* stream);
float* rs, uint16_t* xn16, int64_t T, void* stream, uint16_t* xn16_lo = nullptr);
/// lo16[t, k] = bf16(silu(lo[t, k] / hc))
void gr_silu(const float* lo, uint16_t* lo16, int64_t T, void* stream);
void gr_silu(const float* lo, uint16_t* lo16, int64_t T, void* stream, uint16_t* lo16_lo = nullptr);
/// mixed[t, d] = mean_c xn[t, c, d] * sigmoid(gated[t, c, d]); FP32, BF16 and FP16 (either image may be null).
void gr_mix(const float* xn, const float* gated, float* mixed, uint16_t* mixed16, int64_t T, void* stream,
uint16_t* mixed_h = nullptr);
uint16_t* mixed_h = nullptr, uint16_t* mixed16_lo = nullptr);
/// R[t, c, d] += bo[t, d] * 2 sigmoid(inj[t, c] / hc) (inj has row stride inj_ld)
void gr_write(float* R, const float* bo, const float* inj, int64_t inj_ld, int64_t T, void* stream);
/// R[t, c, :] = e[t, :] for all four streams (the embedding broadcast).
@@ -85,7 +88,7 @@ void kv_append(const float* K, const float* V, int64_t T, int64_t pos0, const in
/// fp32 -> fp16 bits and fp32 -> bf16, n elements (the two activation images of the prompt GEMMs).
void to_f16(const float* x, uint16_t* y, int64_t n, void* stream);
void to_bf16(const float* x, uint16_t* y, int64_t n, void* stream);
void to_bf16(const float* x, uint16_t* y, int64_t n, void* stream, uint16_t* ylo = nullptr);
/// y = fp32(fp16(x)): what an FP16 store of x would read back (the FP16 indexer-key experiment)
void round_f16(const float* x, float* y, int64_t n, void* stream);
/// Expert blob -> FP16 (Q2_0 values are exact in FP16).
+64
View File
@@ -0,0 +1,64 @@
// include/strata/prefill/moe_fused.hpp - #136 (prompt speed): the routed experts of a Q2_0 pack in the prompt path on
// Strata's own int8 tensor-core kernels, opt-in with STRATA_PF_FUSED=1 (NVIDIA sm_80 and newer; sm_75 and HIP: MMQ).
//
// The MMQ path (moe_mmq.hpp) per MoE layer: a host sync to group the (token, k) pairs by expert, a gather of each
// expert into a 16-expert group buffer, then per group the q8_1 activations of every routed slot, gate/up, SwiGLU,
// the q8_1 of H and down - five passes over per-slot buffers. Here:
// - the activations are rounded to int8 once per TOKEN (not per slot), with a scale per 32 values;
// - the grouping (counts, offsets, the rows of each expert, a list of 64-row tiles) runs on the GPU from the
// router's ids, so the host never waits for the routing;
// - one launch per batch of up to kMaxBatch experts reads each expert where it already is (a resident cache slot
// or a slot of the streamed ring: a pointer per expert, no gather) - gate/up with SwiGLU and the int8 rounding
// of H in its epilogue, then down into the per-slot rows the existing combine reads.
// The 2-bit codes are unpacked in registers and multiplied with mma.sync m16n8k32 (s8 x s8 -> s32); Q2_0's offset
// (w = d * (q - 1)) is folded into the activation blocks' sums. The numbers differ from MMQ's (another int8
// rounding of the activations and of H) by about as much as MMQ differs from FP32 (tests/cuda/prefill_fused_moe_test).
#pragma once
#include <cstddef>
#include <cstdint>
namespace strata::prefill::fused {
/// Routed rows (token, k pairs of one expert) per tile.
constexpr int kTileRows = 64;
/// Experts per launch: their blob pointers travel in the launch's parameters (1 KB).
constexpr int kMaxBatch = 128;
/// This build has the kernels (a CUDA build; HIP and builds without MMQ do not).
bool built();
/// built(), and the current device is sm_80 or newer (mma.sync with s8 operands at m16n8k32).
bool available();
/// available(), unless STRATA_PF_FUSED=0 (read once): the Q2_0 pack's fused experts, on by default since 0.1.36.
bool enabled();
/// STRATA_PF_FUSED=1 given explicitly, and available(): the native IQ packs' fused kernels (opt-in).
bool requested();
/// Bytes of `rows` activation rows of `cols` values (a multiple of 64) in the kernels' int8 form: per 64 values 64
/// codes and {d, c} of each half (c = -(1.5 * 2^23 + the half's code sum), what the epilogue adds back).
size_t act_bytes(int64_t rows, int64_t cols);
/// Bytes of the grouping tables for `n` routed rows over `n_expert` experts.
size_t group_bytes(int64_t n, int n_expert);
/// x [rows][cols] FP32 -> `xa` (act_bytes(rows, cols)).
void quantize_act(const float* x, int64_t rows, int64_t cols, void* xa, void* stream);
/// The routing `ids` [n = tokens * k] -> per expert its rows (counts, offsets, the 64-row tile list, in `scratch`,
/// group_bytes(n, n_expert)), `slot[i]` = the row of pair i (the row of the per-slot outputs the combine reads) and
/// `src[row]` = its token (i / k). The order of the rows within an expert is not fixed; no output depends on it.
void group(const int32_t* ids, int64_t n, int k, int n_expert, void* scratch, int32_t* slot, int32_t* src,
void* stream);
/// Experts [e0, e1) of one layer (e1 - e0 <= kMaxBatch); blob[e - e0]: the Strata Q2_0 blob of expert e on the
/// device (16-byte aligned), read only for an expert with rows.
struct Batch {
int e0 = 0, e1 = 0;
const uint8_t* blob[kMaxBatch] = {};
};
/// The batch's products: gate/up from `xa` (quantize_act of the layer's input, [tokens][2560]) through the rows
/// `src` of `group`, SwiGLU, H as int8 into `ha` (act_bytes(n, 640)), down into `dm` [n][2560] FP32 at the rows of
/// `group`. `n`: the layer's routed rows (tokens * k), which bounds the launch grid.
void experts(const Batch& b, int n_expert, int64_t n, const void* scratch, const void* xa, const int32_t* src, void* ha,
float* dm, void* stream);
} // namespace strata::prefill::fused
+41
View File
@@ -0,0 +1,41 @@
// include/strata/prefill/moe_fused_iq.hpp - #136: the fused int8 prompt experts (moe_fused.hpp) for the native GGUF
// packs (tools/iq_pack.py: an expert is its raw GGUF slices [gate rows | up rows | down rows], formats per layer in
// expert_layout().fmt), opt-in with the same STRATA_PF_FUSED=1 (NVIDIA sm_80 and newer; sm_75 and HIP: MMQ).
//
// The grouping on the GPU (fused::group), the per-expert pointers (fused::Batch: a cache slot or a ring slot, no
// gather) and the launch shape are the Q2_0 path's. What differs is the load stage: the i-quant blocks are decoded to
// int8 in shared memory before the mma (codebook and sign lookups, as llama.cpp's MMQ load_tiles does), with the
// block's scale per 32 (or per 16: IQ2_XS, IQ2_S, multiplied at m16n8k16) beside them. The activations and H are
// int8 per 32 values in their natural order here (quantize_act_native), not the Q2_0 path's permuted one.
//
// Covered: gate/up IQ2_XXS, IQ2_XS, IQ2_S, IQ3_XXS, IQ3_S, IQ4_XS; down Q2_0, IQ4_NL - every layer of the IQ2_XS,
// IQ3_XXS and IQ3_S packs but the IQ1_M ones (which MMQ does not cover either). Other layers keep MMQ.
#pragma once
#include "strata/prefill/moe_fused.hpp"
#include <cstddef>
#include <cstdint>
namespace strata::prefill::fused {
/// One layer's native expert geometry (cpu::NativeFmt's fields the kernels read; n_embd 2560, n_ff 640).
struct NativeGeom {
int gu_type = -1, d_type = -1; ///< ggml types
size_t gu_row = 0, d_row = 0; ///< bytes per gate/up row and per down row
size_t up_off = 0, down_off = 0; ///< inside the blob
};
/// enabled() (STRATA_PF_FUSED=1 on sm_80+), and the kernels cover this gate/up and down pair on this device.
bool native_supported(int gu_type, int d_type);
/// x [rows][cols] FP32 -> `xa` (act_bytes(rows, cols)): int8 per 32 values in natural order, the native kernels' form.
void quantize_act_native(const float* x, int64_t rows, int64_t cols, void* xa, void* stream);
/// experts() for native blobs: b.blob[e - e0] is the native blob of expert e (gate at 0, up at g.up_off, down at
/// g.down_off; 2-byte aligned). `xa` from quantize_act_native; H in `ha` (act_bytes(n, 640)); down into `dm` at the
/// rows of `group`.
void experts_native(const Batch& b, const NativeGeom& g, int n_expert, int64_t n, const void* scratch, const void* xa,
const int32_t* src, void* ha, float* dm, void* stream);
} // namespace strata::prefill::fused
+6 -1
View File
@@ -12,8 +12,13 @@ namespace strata::prefill::mmq {
/// This build has the MMQ path (the ggml sources were available to the build).
bool built();
/// MMQ covers this ggml type (the i-quants and Q2_0 the packs use; IQ1_M is not covered).
/// MMQ covers this ggml type (the i-quants and Q2_0 the packs use, Q8_0, and in a CUDA build with STRATA_MMQ_KQUANTS
/// the Q4_K / Q5_K / Q5_1 of Unsloth's UD-Q4_K_XL; IQ1_M is not covered).
bool supported(int ggml_type);
/// #420: `supported`, and on every visible GPU llama.cpp's MMQ has a tile for this type and a weight matrix of
/// `w_rows` rows that fits the card's shared memory - the same test its tile choice makes, which aborts the process
/// ("J_best=0") when nothing fits. false (said once per type) keeps that product on the non-MMQ path.
bool fits(int ggml_type, int64_t w_rows);
/// Bytes of one expert's gate+up ([2*n_ff, n_embd]) or down ([n_embd, n_ff]) weights in `ggml_type`.
size_t matrix_bytes(int ggml_type, int64_t rows, int64_t cols);
/// Bytes of `rows` activation rows of `cols` values quantized for MMQ (the row padded to 512 values).
+3
View File
@@ -73,6 +73,9 @@ public:
/// ring (a big one only pays when the copy engine, not the host copies, is the limit); set before bytes_needed.
static void set_pinned_share(double share);
static double pinned_share();
/// #340: the streamed ring's slot count for chunks that stream every expert, instead of the pinned-share rule
/// (0 = that rule). Set before any `bytes_needed`/`init` (both count the ring); STRATA_PREFILL_RING still wins.
static void set_ring_override(int slots);
/// Device bytes `init` needs for a chunk of `chunk` tokens (what a borrowed region must hold).
static uint64_t bytes_needed(const core::ModelGeometry& g, const core::SessionState& ss, int64_t chunk);
+40 -6
View File
@@ -25,12 +25,17 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment
# ------------------------------------------------------------------------------------------------ template
class TemplateRequestError(jinja2.exceptions.TemplateError, ValueError):
"""The template refused the request's messages (e.g. "No user query found in messages."): a ValueError, so the
client gets a 400 with the template's message instead of a dropped connection (#365)."""
class ChatTemplate:
"""The model's chat template, rendered with the same Jinja settings as transformers' apply_chat_template."""
def __init__(self, path: str | Path):
def raise_exception(message):
raise jinja2.exceptions.TemplateError(message)
raise TemplateRequestError(message)
def tojson(x, ensure_ascii=False, indent=None, separators=None, sort_keys=False):
return json.dumps(x, ensure_ascii=ensure_ascii, indent=indent, separators=separators, sort_keys=sort_keys)
@@ -138,10 +143,27 @@ def _late_system_to_user(messages: list[dict]) -> list[dict]:
return [dict(m, role="user") if m.get("role") == "system" and i > 0 else m for i, m in enumerate(messages)]
def _object_list(value, name: str) -> list[dict]:
"""#460: a request's "messages" (or a message's "tool_calls") as a list of objects. Some clients send the array
double-encoded, as a JSON string, which used to be iterated character by character and crashed on m.get: such a
string is decoded. Anything that is still not a list of objects is a ValueError, which the server answers with
a 400 naming the field. None (or no field) is an empty list, as a missing field always was."""
if value is None:
return []
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
raise ValueError(f"{name} must be a list of objects (a string was sent that is not JSON)") from None
if not isinstance(value, list) or not all(isinstance(m, dict) for m in value):
raise ValueError(f"{name} must be a list of objects")
return value
def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
"""OpenAI Chat Completions -> (template messages, template tools, template kwargs)."""
messages = []
for m in req.get("messages", []):
for m in _object_list(req.get("messages"), "messages"):
role = m.get("role")
if role == "developer":
role = "system"
@@ -150,8 +172,10 @@ def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
out["reasoning_content"] = m["reasoning_content"]
if m.get("tool_calls"):
calls = []
for c in m["tool_calls"]:
for c in _object_list(m["tool_calls"], "tool_calls"):
fn = c.get("function", c)
if not isinstance(fn, dict):
raise ValueError("tool_calls must be a list of objects (each with a \"function\" object)")
args = fn.get("arguments")
if isinstance(args, str): # the template requires a mapping, not a JSON string
args = json.loads(args) if args.strip() else {}
@@ -173,13 +197,15 @@ def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
return _late_system_to_user(messages), tools, kwargs
def anthropic_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
"""Anthropic Messages -> (template messages, template tools, template kwargs)."""
def anthropic_to_messages(req: dict, think_unasked: bool = True) -> tuple[list[dict], list[dict] | None, dict]:
"""Anthropic Messages -> (template messages, template tools, template kwargs). `think_unasked`: a request
without "thinking", an effort or a budget gets the template's default (it thinks), as through 0.1.31; False
renders it without thinking (#278, the config's "anthropic_thinking": "on_request")."""
messages = []
system = req.get("system")
if system:
messages.append({"role": "system", "content": _text_of(system)})
for m in req.get("messages", []):
for m in _object_list(req.get("messages"), "messages"):
content = m.get("content")
if isinstance(content, str):
messages.append({"role": m["role"], "content": content})
@@ -219,6 +245,14 @@ def anthropic_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dic
kwargs.update(effort_kwargs(effort))
elif isinstance(thinking, dict) and thinking.get("budget_tokens"):
kwargs.update(budget_effort(thinking["budget_tokens"]))
elif thinking is None and not req.get("reasoning_budget_tokens") and not think_unasked:
# Opt-in (the config's "anthropic_thinking": "on_request"; the default thinks as 0.1.31 did, since a
# client that never asks would otherwise lose the thinking on every turn). Anthropic's thinking is
# opt-in there. Claude Code's helper calls (a session title, a topic check) ask for none
# and allow a few dozen tokens, which the model otherwise spent thinking and answered with no text at all.
# A config's reasoning_effort still applies: Service.with_shared sets output_config before this runs. A
# request that gives its own reasoning_budget_tokens (#123) asks for thinking, so it thinks as before.
kwargs["enable_thinking"] = False
return _late_system_to_user(messages), tools, kwargs
+673 -79
View File
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
"""JSON response formats at the HTTP boundary: prompt once, validate before delivery.
The native engine has no grammar decoder. Failed generations are errors, never
silently retried or returned as successful structured output.
`jsonschema` is optional (no new hard dependency of the server): json_object needs only the standard library, and
json_schema is checked against its schema when the package is installed - without it, only that the answer is one
JSON object, and the server says so once.
"""
import json
import threading
class StructuredOutputError(RuntimeError):
pass
class _ObjectOnly:
"""The validator for json_object, and for json_schema without jsonschema: one JSON object."""
def iter_errors(self, value):
if not isinstance(value, dict):
yield _ObjectError()
class _ObjectError:
absolute_path = ()
message = "the answer is not a JSON object"
_jsonschema = None # (validators, SchemaError, Registry, NoSuchResource), False when not installed
_jsonschema_lock = threading.Lock()
def jsonschema_modules():
"""jsonschema, imported on first use; None when it is not installed (said once, in the server window)."""
global _jsonschema
with _jsonschema_lock:
if _jsonschema is None:
try:
from jsonschema import validators
from jsonschema.exceptions import SchemaError
from referencing import Registry
from referencing.exceptions import NoSuchResource
_jsonschema = (validators, SchemaError, Registry, NoSuchResource)
except ImportError:
_jsonschema = False
print('[strata] response_format json_schema: the Python package jsonschema is not installed, so '
'answers are only checked to be one JSON object (python -m pip install "jsonschema>=4.23,<5")',
flush=True)
return _jsonschema or None
def prepare_format(response_format, messages):
if response_format is None:
return messages, None
if not isinstance(response_format, dict):
raise ValueError("response_format must be an object")
kind = response_format.get("type")
if kind == "text":
return messages, None
if kind == "json_object":
schema = {"type": "object"}
modules = None # the standard library is enough for "one JSON object"
elif kind == "json_schema":
spec = response_format.get("json_schema")
if not isinstance(spec, dict) or not isinstance(spec.get("schema"), dict):
raise ValueError("response_format.json_schema needs a schema object")
if not isinstance(spec.get("name"), str) or not spec["name"]:
raise ValueError("response_format.json_schema needs a name")
if "strict" in spec and not isinstance(spec["strict"], bool):
raise ValueError("response_format.json_schema.strict must be boolean")
schema = spec["schema"]
if schema.get("type") != "object":
raise ValueError("response_format schema must have type object at its root")
modules = jsonschema_modules()
else:
raise ValueError("response_format.type must be text, json_object or json_schema")
def check_refs(node):
if isinstance(node, dict):
for key, value in node.items():
if key in ("$ref", "$dynamicRef") and isinstance(value, str) and not value.startswith("#"):
raise ValueError("response_format supports only local schema references (#...)")
check_refs(value)
elif isinstance(node, list):
for value in node:
check_refs(value)
check_refs(schema)
if modules is None:
validator = _ObjectOnly()
else:
validators, SchemaError, Registry, NoSuchResource = modules
def no_remote(uri):
raise NoSuchResource(ref=uri)
try:
cls = validators.validator_for(schema)
cls.check_schema(schema)
validator = cls(schema, registry=Registry(retrieve=no_remote))
except SchemaError as exc:
raise ValueError(f"invalid response_format schema: {exc.message}") from exc
directive = ("OUTPUT FORMAT REQUIREMENT: Return exactly one JSON object matching the JSON Schema below. "
"No Markdown, headings, code fences, commentary, or text outside the JSON. "
"Use every required field, correct types, and only allowed fields. "
"Put all requested writing inside the appropriate JSON string fields.\nJSON Schema:\n" +
json.dumps(schema, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
messages = [dict(message) for message in messages]
if messages and messages[0].get("role") == "system":
content = messages[0].get("content") or ""
if isinstance(content, list):
messages[0]["content"] = content + [{"type": "text", "text": directive}]
else:
messages[0]["content"] = content + "\n\n" + directive
else:
messages.insert(0, {"role": "system", "content": directive})
return messages, validator
def validated_json(text, validator, finish):
def pairs(items):
obj = {}
for key, value in items:
if key in obj:
raise ValueError(f"duplicate JSON key: {key}")
obj[key] = value
return obj
def constant(value):
raise ValueError(f"invalid JSON constant: {value}")
if finish != "stop":
raise StructuredOutputError(f"structured output was incomplete (finish_reason={finish}); increase the output budget")
try:
value = json.loads(text or "", object_pairs_hook=pairs, parse_constant=constant)
canonical = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
except (ValueError, TypeError) as exc:
raise StructuredOutputError(f"model did not return valid JSON: {exc}") from exc
try:
error = next(validator.iter_errors(value), None)
except Exception as exc:
raise StructuredOutputError(f"could not validate structured output: {exc}") from exc
if error is not None:
path = "/" + "/".join(str(part) for part in error.absolute_path)
raise StructuredOutputError(f"model output failed the JSON Schema at {path}: {error.message}")
return canonical
+100 -4
View File
@@ -2,7 +2,8 @@
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.
pip package is needed: load, VRAM, temperature, power, PCIe link and throughput. With the AMD backend (#301): the
amdgpu driver's Linux sysfs files - load, VRAM, temperature and power.
- 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.
@@ -103,6 +104,100 @@ class _Nvml:
return out
# ------------------------------------------------------------------------------------------------ AMD (Linux sysfs)
SYSFS = "/sys"
def amd_device_dir(index, sysfs=None):
"""The amdgpu sysfs folder (/sys/class/drm/renderD<N>/device) of the AMD GPU that HIP numbers `index`: the KFD
topology's GPU nodes in order, the CPU nodes skipped, linked to their render node by drm_render_minor - the
numbering setup's amd_gpus() and HIP_VISIBLE_DEVICES use. None when there is no such card (or no amdgpu)."""
base = os.path.join(sysfs or SYSFS, "class", "kfd", "kfd", "topology", "nodes")
try:
nodes = sorted((n for n in os.listdir(base) if n.isdigit()), key=int)
except OSError:
return None
gpus = []
for n in nodes:
try:
with open(os.path.join(base, n, "properties"), encoding="utf-8") as f:
props = dict(line.strip().partition(" ")[::2] for line in f if line.strip())
if int(props.get("gfx_target_version") or 0) == 0 or int(props.get("simd_count") or 0) == 0:
continue
gpus.append(props)
except (OSError, ValueError):
continue
if not 0 <= index < len(gpus) or not gpus[index].get("drm_render_minor"):
return None
dev = os.path.join(sysfs or SYSFS, "class", "drm", "renderD" + gpus[index]["drm_render_minor"].strip(), "device")
return dev if os.path.isdir(dev) else None
class _Amd:
"""#301: an AMD card's readings from the amdgpu driver's sysfs files (Linux; no ROCm library needed), with _Nvml's
interface: load (gpu_busy_percent), VRAM (mem_info_vram_used / _total), and from its hwmon folder the temperature
(temp1_input, the edge sensor, m°C), power (power1_average or power1_input, µW) and its cap (power1_cap)."""
def __init__(self, index=0, sysfs=None):
self.dev = amd_device_dir(index, sysfs)
self.hwmon = None
if self.dev:
try:
hw = sorted(os.listdir(os.path.join(self.dev, "hwmon")))
self.hwmon = os.path.join(self.dev, "hwmon", hw[0]) if hw else None
except OSError:
pass
def ok(self):
return self.dev is not None
@staticmethod
def _int(path):
try:
with open(path, encoding="utf-8") as f:
return int(f.read().strip())
except (OSError, ValueError, TypeError):
return None
def name(self):
try:
with open(os.path.join(self.dev, "product_name"), encoding="utf-8") as f:
return f.read().strip() or "AMD Radeon"
except (OSError, TypeError):
return "AMD Radeon"
def read(self):
out = {"util": self._int(os.path.join(self.dev, "gpu_busy_percent")),
"mem_used": self._int(os.path.join(self.dev, "mem_info_vram_used")),
"mem_total": self._int(os.path.join(self.dev, "mem_info_vram_total"))}
if self.hwmon:
t = self._int(os.path.join(self.hwmon, "temp1_input"))
out["temp"] = t / 1000.0 if t is not None else None
p = self._int(os.path.join(self.hwmon, "power1_average"))
if p is None:
p = self._int(os.path.join(self.hwmon, "power1_input"))
out["power"] = p / 1e6 if p is not None else None
cap = self._int(os.path.join(self.hwmon, "power1_cap"))
out["power_limit"] = cap / 1e6 if cap is not None else None
return out
def gpu_reader(index=0, amd=False):
"""The card's readings: NVML (NVIDIA), or the amdgpu sysfs files with the AMD backend (#301)."""
return _Amd(index) if amd else _Nvml(index)
def free_vram_mib(index=0, amd=False):
"""Free VRAM of a card in MiB, or None when it cannot be read."""
g = gpu_reader(index, amd)
if not g.ok():
return None
r = g.read()
if r.get("mem_total") is None or r.get("mem_used") is None:
return None
return int((r["mem_total"] - r["mem_used"]) >> 20)
# ------------------------------------------------------------------------------------------------ CPU / RAM
def _cpu_name():
if os.name == "nt":
@@ -169,17 +264,18 @@ class _CpuRamFallback:
# ------------------------------------------------------------------------------------------------ the sampler
class Telemetry:
def __init__(self, extra=None, gpu_index=0, gpu_indices=None):
def __init__(self, extra=None, gpu_index=0, gpu_indices=None, amd=False):
"""`extra()` -> dict of more series to record each second (the server's tok/s). `gpu_index`: the card the
engine runs on, numbered as nvidia-smi and NVML number them (by PCI bus); `gpu_indices`: all of them when
the model is split across several (issue #112) - the gpu_* readings are then their total (memory, power,
PCIe traffic), mean (load) or hottest (temperature), and "gpus" has each card's own."""
PCIe traffic), mean (load) or hottest (temperature), and "gpus" has each card's own. `amd`: the AMD backend's
cards, numbered as HIP numbers them, read from sysfs (#301)."""
self.extra = extra
self.lock = threading.Lock()
self.now: dict = {}
self.hist = collections.defaultdict(lambda: collections.deque(maxlen=HISTORY))
idx = list(gpu_indices) if gpu_indices and len(gpu_indices) > 1 else [gpu_index]
self.gpus = [(i, _Nvml(i)) for i in idx]
self.gpus = [(i, gpu_reader(i, amd)) for i in idx]
self.gpus = [(i, g) for i, g in self.gpus if g.ok()] or self.gpus[:1]
self.gpu = self.gpus[0][1]
try:
+170
View File
@@ -0,0 +1,170 @@
"""Model discovery, on-demand loading and safe unloading over the actual HTTP API."""
import json
import io
import subprocess
import time
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
from serve.frontend import ChatTemplate
from serve.server import ByteTokenizer, EngineDied, EngineStuck, MockEngine, Service, StrataEngine, serve
class ResidentEngine(StrataEngine):
def __init__(self, tok):
self.reply = MockEngine(tok, "Hello.", max_context=4096)
self.max_context, self.info, self.last = 4096, {}, {}
self.loaded, self.starts, self.closes = False, 0, 0
self.unloaded = True
self.progress = None
def alive(self):
return self.loaded
def exit_code(self):
return None
def restart(self):
self.loaded = True
self.starts += 1
self.unloaded = False
def close(self):
self.loaded = False
self.closes += 1
def unload(self):
self.close()
self.unloaded = True
def generate(self, *args, **kwargs):
yield from self.reply.generate(*args, **kwargs)
class Lifecycle(unittest.TestCase):
def test_close_sends_eof_before_waiting_for_windows_reader(self):
engine = StrataEngine("missing-executable", [], lazy=True)
proc = mock.Mock()
proc.stdin, proc.stdout = io.StringIO(), io.StringIO()
proc.poll.side_effect = [None, 0]
proc.wait.side_effect = lambda **kwargs: self.assertTrue(proc.stdin.closed)
engine.proc = proc
engine.close()
self.assertIsNone(engine.proc)
proc.kill.assert_not_called()
def test_close_does_not_forget_a_process_still_exiting(self):
engine = StrataEngine("missing-executable", [], lazy=True)
proc = mock.Mock()
proc.stdin, proc.stdout = io.StringIO(), io.StringIO()
proc.poll.return_value = None
proc.wait.side_effect = subprocess.TimeoutExpired("engine", 2)
engine.proc = proc
with self.assertRaisesRegex(EngineStuck, "still releasing"):
engine.close()
self.assertIs(engine.proc, proc)
proc.terminate.assert_called_once()
proc.kill.assert_called_once()
def test_close_terminates_before_it_kills(self):
# an engine that does not end on QUIT is terminated first (as the unload always did); kill is the last resort
engine = StrataEngine("missing-executable", [], lazy=True)
proc = mock.Mock()
proc.stdin, proc.stdout = io.StringIO(), io.StringIO()
proc.poll.side_effect = [None, 0]
proc.wait.side_effect = [subprocess.TimeoutExpired("engine", 20), 0]
engine.proc = proc
engine.close()
proc.terminate.assert_called_once()
proc.kill.assert_not_called()
self.assertIsNone(engine.proc)
def test_native_lazy_constructor_does_not_start_a_process(self):
engine = StrataEngine("missing-executable", ["--max-context", "16384"], lazy=True)
self.assertFalse(engine.alive())
self.assertEqual(engine.max_context, 16384)
self.assertIsNone(engine.exit_code())
engine.close()
def setUp(self):
tok = ByteTokenizer()
self.engine = ResidentEngine(tok)
self.svc = Service(self.engine, tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
self.httpd = serve(self.svc, port=0)
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
def request(self, path, body=None, headers=None):
req = urllib.request.Request(self.base + path, data=json.dumps(body).encode() if body is not None else None,
headers={"Content-Type": "application/json", **(headers or {})})
try:
response = urllib.request.urlopen(req, timeout=10)
except urllib.error.HTTPError as error:
response = error
with response:
data = response.read().decode()
return response.status, data if "event-stream" in response.headers.get("Content-Type", "") else json.loads(data)
def test_discover_chat_unload_and_stream_reload(self):
self.assertEqual(self.request("/api/health")[1]["service"], "strata")
self.assertFalse(self.request("/v1/status")[1]["loaded"])
self.assertEqual(self.request("/v1/models")[1]["data"][0]["status"]["value"], "unloaded")
self.assertTrue(self.request("/props")[1]["models_autoload"])
self.assertEqual(self.engine.starts, 0)
chat = {"messages": [{"role": "user", "content": "Hello"}], "max_tokens": 32, "reasoning_effort": "none"}
for _ in range(2):
code, answer = self.request("/v1/chat/completions", chat)
self.assertEqual(code, 200)
self.assertEqual(answer["choices"][0]["message"]["content"], "Hello.")
self.assertEqual(self.engine.starts, 1)
self.assertFalse(self.request("/v1/unload", {})[1]["loaded"])
code, stream = self.request("/v1/chat/completions", {**chat, "stream": True})
self.assertEqual(code, 200)
self.assertIn("data: [DONE]", stream)
self.assertEqual(self.engine.starts, 2)
def test_busy_auth_and_foreign_origin_cannot_unload(self):
self.engine.loaded = True
with self.svc.fifo:
self.assertEqual(self.request("/v1/unload", {})[0], 409)
self.svc.api_key = "local-secret"
self.assertEqual(self.request("/v1/unload", {})[0], 401)
self.assertEqual(self.request("/v1/unload", {}, {"Authorization": "Bearer local-secret",
"Origin": "https://other.example"})[0], 403)
self.assertEqual(self.engine.closes, 0)
self.assertTrue(self.engine.loaded)
self.assertEqual(self.request("/v1/unload", {}, {"Authorization": "Bearer local-secret"})[0], 200)
def test_an_engine_that_died_on_load_is_reported_as_such(self):
# EngineDied is a RuntimeError: it keeps its own answer ("the next request restarts it")
def died():
raise EngineDied("the engine ended while it started")
self.engine.restart = died
code, body = self.request("/v1/chat/completions", {"messages": [{"role": "user", "content": "Hello"}]})
self.assertEqual(code, 503)
self.assertIn("the next request restarts it", body["error"]["message"])
def test_explicit_load_and_model_matching(self):
self.assertEqual(self.request("/v1/load", {"model": "other-model"})[0], 404)
self.assertEqual(self.engine.starts, 0)
before = time.time()
code, status = self.request("/v1/load", {"model": self.svc.model})
self.assertEqual(code, 200)
self.assertTrue(status["loaded"])
self.assertEqual(self.engine.starts, 1)
self.assertGreaterEqual(self.svc.last_request_at, before)
self.svc.status["queued"] = 1
self.assertEqual(self.request("/v1/load", {})[0], 409)
self.assertEqual(self.request("/v1/unload", {})[0], 409)
self.assertEqual(self.engine.closes, 0)
self.assertEqual(self.request("/v1/load", {}, {"Content-Type": "text/plain"})[0], 415)
if __name__ == "__main__":
unittest.main()
+198
View File
@@ -0,0 +1,198 @@
"""Request inspection through HTTP; no native model or GPU is required."""
import json
import threading
import time
import unittest
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest import mock
from serve.frontend import ChatTemplate
from serve.server import ByteTokenizer, EngineDied, MockEngine, Service, serve
class ReloadableEngine(MockEngine):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.running, self.unloaded = True, False
def alive(self):
return self.running
def unload(self):
self.running, self.unloaded = False, True
def restart(self):
self.running, self.unloaded = True, False
class MonitorOff(unittest.TestCase):
"""#332 is opt-in: by default no prompt or answer is kept, and the monitor's endpoints do not exist."""
def test_off_by_default(self):
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "Hello.", max_context=4096), tok,
ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
req = urllib.request.Request(base + "/v1/chat/completions", headers={"Content-Type": "application/json"},
data=json.dumps({"messages": [{"role": "user", "content": "secret"}]}).encode())
with urllib.request.urlopen(req, timeout=10) as r:
self.assertEqual(r.status, 200)
self.assertEqual(len(svc.api_requests), 0)
for path in ("/api/requests", "/api-monitor"):
with self.assertRaises(urllib.error.HTTPError) as e:
urllib.request.urlopen(base + path, timeout=10)
self.assertEqual(e.exception.code, 404, path)
e.exception.close()
finally:
httpd.shutdown()
httpd.server_close()
class Monitor(unittest.TestCase):
def setUp(self):
self.tok = ByteTokenizer()
self.engine = ReloadableEngine(self.tok, "Thought.</think>\nHello <script>alert(1)</script>.", max_context=16384)
self.svc = Service(self.engine, self.tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
self.svc.api_monitor = True # opt-in ("api_monitor": true / --api-monitor)
self.httpd = serve(self.svc, port=0)
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
def request(self, path, body=None, headers=None):
req = urllib.request.Request(self.base + path, data=json.dumps(body).encode() if body is not None else None,
headers={"Content-Type": "application/json", **(headers or {})})
try:
response = urllib.request.urlopen(req, timeout=10)
except urllib.error.HTTPError as error:
response = error
with response:
raw = response.read().decode()
return response.status, json.loads(raw) if "application/json" in response.headers.get("Content-Type", "") else raw
def chat(self, api="openai", **kwargs):
# Anthropic thinks only when asked (#278): "thinking" there, reasoning_effort for OpenAI
think = {"thinking": {"type": "enabled", "budget_tokens": 1024}} if api == "anthropic" else {"reasoning_effort": "low"}
return self.request("/v1/messages" if api == "anthropic" else "/v1/chat/completions",
{"messages": [{"role": "user", "content": "Say hello."}],
"max_tokens": 256, **think, **kwargs})
def latest(self):
summary = self.request("/api/requests")[1]["requests"][0]
return self.request("/api/requests?id=" + summary["id"])[1]
def test_output_and_reasoning_for_both_dialects_and_stream_modes(self):
for api in ("openai", "anthropic"):
for stream in (False, True):
with self.subTest(api=api, stream=stream):
code, reply = self.chat(api, stream=stream)
self.assertEqual(code, 200)
record = self.latest()
self.assertEqual(record["state"], "completed")
self.assertEqual(record["http_status"], 200)
self.assertIn("Say hello.", record["input"])
self.assertIn("<script>alert(1)</script>", record["output"])
self.assertIn("Thought.", record["reasoning"])
self.assertGreater(record["usage"].get("completion_tokens", record["usage"].get("output_tokens", 0)), 0)
self.assertGreaterEqual(record["wallclock_s"], record["first_token_s"])
self.assertNotIn("_clock", record)
if not stream:
self.assertEqual(json.loads(record["response"]), reply)
else:
self.assertNotIn("response", record)
def test_error_and_engine_death_are_not_successes(self):
self.assertEqual(self.chat(max_tokens=20000)[0], 400)
self.assertEqual(self.latest()["state"], "error")
for api in ("openai", "anthropic"):
for stream in (False, True):
with self.subTest(api=api, stream=stream), mock.patch.object(self.engine, "generate", side_effect=EngineDied("stopped")):
self.chat(api, stream=stream)
record = self.latest()
self.assertEqual(record["state"], "error")
self.assertIn("stopped", record["error"]["message"])
self.assertEqual(record["output"], "")
self.assertNotIn("timings", record)
def test_history_is_authorized_and_does_not_capture_headers(self):
self.svc.api_key = "secret"
auth = {"Authorization": "Bearer secret"}
self.assertEqual(self.request("/api/requests")[0], 401)
self.assertEqual(self.request("/api/requests?id=missing")[0], 401)
self.assertEqual(len(self.svc.api_requests), 0)
self.assertEqual(self.request("/v1/chat/completions", {"messages": [{"role": "user", "content": "hi"}]}, auth)[0], 200)
summary = self.request("/api/requests", headers=auth)[1]["requests"][0]
self.assertNotIn("input", summary)
self.assertNotIn("output", summary)
self.assertEqual(self.request("/api/requests?id=" + summary["id"])[0], 401)
detail = self.request("/api/requests?id=" + summary["id"], headers=auth)[1]
self.assertNotIn("secret", json.dumps(detail))
def test_capture_is_bounded_and_eviction_returns_404(self):
huge = "x" * 262200
first = self.svc.begin_request("/v1/chat/completions", {"messages": [{"content": huge}]})
self.assertEqual(len(first["input"]), 262144)
self.assertTrue(first["input_truncated"])
for _ in range(100):
self.svc.begin_request("/v1/chat/completions", {"messages": []})
self.assertEqual(len(self.request("/api/requests")[1]["requests"]), 100)
self.assertEqual(self.request("/api/requests?id=" + first["id"])[0], 404)
# Exercise output capture without making a mock engine emit 262K tokens.
handler = object.__new__(self.httpd.RequestHandlerClass)
handler.record = self.svc.api_requests[-1]
chunk = {"choices": [{"delta": {"content": huge, "reasoning_content": huge}}]}
list(handler._capture((item for item in [chunk]), "openai"))
for field in ("output", "reasoning"):
self.assertEqual(len(handler.record[field]), 262144)
self.assertTrue(handler.record[field + "_truncated"])
def test_queue_wait_and_loading_are_attributed_separately(self):
self.engine.unload()
entered, release = threading.Event(), threading.Event()
original = self.engine.restart
def restart():
entered.set()
self.assertTrue(release.wait(5))
original()
with mock.patch.object(self.engine, "restart", side_effect=restart), ThreadPoolExecutor(max_workers=2) as pool:
first = pool.submit(self.chat)
self.assertTrue(entered.wait(5))
second = pool.submit(self.chat)
deadline = time.monotonic() + 5
while len(self.svc.api_requests) < 2 and time.monotonic() < deadline:
time.sleep(0.01)
self.assertEqual(len(self.svc.api_requests), 2)
history = self.request("/api/requests")[1]
self.assertEqual({r["state"] for r in history["requests"]}, {"queued", "loading"})
self.assertFalse(history["loaded"])
self.assertTrue(history["auto_load"])
time.sleep(0.03)
release.set()
self.assertEqual(first.result()[0], 200)
self.assertEqual(second.result()[0], 200)
records = list(self.svc.api_requests)
self.assertGreater(records[0]["load_s"], 0.02)
self.assertGreater(records[1]["queue_s"], 0.02)
self.assertLess(records[1]["load_s"], records[0]["load_s"])
self.assertTrue(all(r["state"] == "completed" for r in records))
def test_page_and_relative_assets_are_served(self):
code, html = self.request("/api-monitor")
self.assertEqual(code, 200)
self.assertIn('src="/web/monitor.js"', html)
code, script = self.request("/web/monitor.js")
self.assertEqual(code, 200)
self.assertIn("textContent", script)
self.assertNotIn("innerHTML", script)
self.assertEqual(self.request("/web/../server.py")[0], 404)
if __name__ == "__main__":
unittest.main()
+664 -1
View File
@@ -14,10 +14,13 @@ import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from serve.frontend import ChatTemplate # noqa: E402
from serve.server import CTX_SLACK, ByteTokenizer, EngineDied, GpuBusy, MockEngine, Service, StrataEngine, request_timings, serve # noqa: E402
from serve.server import (CTX_SLACK, ByteTokenizer, EngineDied, GpuBusy, MockEngine, Service, StrataEngine, # noqa: E402
engine_args, prompt_tokens_seen, request_timings, serve, start_failure_hint)
from types import SimpleNamespace # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
CTX = 4096
@@ -65,6 +68,43 @@ class MaxTokens(unittest.TestCase):
u = b.get("usage", {})
return s, b, u.get("input_tokens"), u.get("output_tokens")
def test_anthropic_thinks_only_when_asked(self):
# #278: Anthropic's thinking is opt-in; "thinking", an effort or a reasoning_budget_tokens (#123) asks for it
from serve.frontend import anthropic_to_messages
msgs = [{"role": "user", "content": "u"}]
kw = lambda **r: anthropic_to_messages({"messages": msgs, **r}, think_unasked=False)[2] # noqa: E731
# the default ("anthropic_thinking": "model") renders an unasked request as 0.1.31 did
self.assertNotIn("enable_thinking", anthropic_to_messages({"messages": msgs})[2])
self.assertEqual(kw(), {"enable_thinking": False})
self.assertEqual(kw(thinking={"type": "disabled"}), {"enable_thinking": False})
self.assertNotIn("enable_thinking", kw(thinking={"type": "enabled", "budget_tokens": 2048}))
self.assertNotIn("enable_thinking", kw(output_config={"effort": "high"}))
self.assertNotIn("enable_thinking", kw(reasoning_budget_tokens=30))
def test_count_tokens_is_the_prompt_messages_reads(self):
# /v1/messages/count_tokens renders and tokenizes the same prompt /v1/messages would read, without running it
msgs = [{"role": "user", "content": "how many tokens is this?"}]
s, b = self.post("/v1/messages/count_tokens", {"model": "m", "messages": msgs})
self.assertEqual(s, 200)
s2, _, n_in, _ = self.call("anthropic", "how many tokens is this?", max_tokens=8)
self.assertEqual(s2, 200)
self.assertEqual(b["input_tokens"], n_in)
def test_request_line_parses_the_engine_summary(self):
line = ("strata serve: prompt 1200 tokens = 1000 reused + 200 read in 50 ms (4000.0 tok/s), 30 generated in "
"300 ms (100.0 tok/s), drafts accepted 20 of 28, 2 checkpoints")
from serve.server import ENGINE_REQUEST
m = ENGINE_REQUEST.search(line)
self.assertIsNotNone(m)
self.assertEqual((m["prompt"], m["reused"], m["gen"], m["tg"]), ("1200", "1000", "30", "100.0"))
# #471: a request cancelled while its prompt was read says how far it got
m = ENGINE_REQUEST.search("strata serve: prompt 98179 tokens = 0 reused + 12288 of 98179 read in 17565 ms "
"(699.6 tok/s), 0 generated in 0 ms (0.0 tok/s), drafts accepted 0 of 0, "
"0 checkpoints (cancelled)")
self.assertIsNotNone(m)
self.assertEqual((m["prompt"], m["reused"], m["read"], m["pp"], m["gen"]),
("98179", "0", "17565", "699.6", "0"))
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}],
@@ -424,6 +464,55 @@ class ClientShapes(unittest.TestCase):
for part in ("Be brief.", "Now use digits.", "Also this.", "1+1?"):
self.assertIn(part, text)
def test_no_user_turn_is_a_400(self):
# #365: the template's own refusal (Qwen's "No user query found in messages." when no turn is a user's query)
# answers 400, not a dropped connection
with tempfile.TemporaryDirectory() as d:
tpl = Path(d) / "chat_template.jinja"
tpl.write_text("{% if messages[-1].role != 'user' %}{{ raise_exception('No user query found in messages.') }}"
"{% endif %}{{ messages[-1].content }}", encoding="utf-8")
template, self.svc.template = self.svc.template, ChatTemplate(tpl)
try:
status, b = self.post("/v1/chat/completions", {
"model": "x", "max_tokens": 20, "messages": [{"role": "system", "content": "Only a system."}]})
self.assertEqual(status, 400, b)
self.assertIn("No user query found", b["error"]["message"])
finally:
self.svc.template = template
status, b = self.post("/v1/chat/completions", {"model": "x", "max_tokens": 20,
"messages": [{"role": "user", "content": "hi"}]})
self.assertEqual(status, 200, b) # the server goes on
def test_messages_as_a_json_string_over_http(self):
# #460: a double-encoded "messages" is answered; one that is not a list of objects is a 400, not a 500
encoded = json.dumps([{"role": "user", "content": "1+1?"}])
for path in ("/v1/chat/completions", "/v1/messages"):
with self.subTest(path=path):
status, b = self.post(path, {"model": "x", "max_tokens": 20, "messages": encoded})
self.assertEqual(status, 200, b)
self.assertIn("1+1?", self.prompt_text())
status, b = self.post(path, {"model": "x", "max_tokens": 20, "messages": ["hi"]})
self.assertEqual(status, 400, b)
self.assertIn("messages must be a list of objects", b["error"]["message"])
def test_vision_temp_image_removed_when_the_pipe_fails(self):
# #352: the temporary image goes even when the encoder's pipe raises
from serve.server import Vision
class Gone:
def write(self, _):
raise BrokenPipeError("the encoder is gone")
v = Vision.__new__(Vision)
v.dir, v.lock, v.cache = Path(tempfile.mkdtemp(prefix="strata-vision-test-")), threading.Lock(), {}
v.proc = mock.Mock(stdin=Gone())
with mock.patch.object(Vision, "load", return_value=b""), mock.patch.object(Vision, "normalize",
return_value=b"png"):
with self.assertRaises(BrokenPipeError):
v.encode("x")
self.assertEqual(list(v.dir.iterdir()), [])
v.dir.rmdir()
def test_leading_system_unchanged(self):
from serve.frontend import anthropic_to_messages, openai_to_messages
msgs, _, _ = openai_to_messages({"messages": [{"role": "developer", "content": "D"}, {"role": "user", "content": "u"}]})
@@ -431,6 +520,32 @@ class ClientShapes(unittest.TestCase):
msgs, _, _ = anthropic_to_messages({"system": "S", "messages": [{"role": "user", "content": "u"}]})
self.assertEqual([m["role"] for m in msgs], ["system", "user"])
def test_messages_sent_as_a_json_string(self):
# #460: a client that double-encodes "messages" (and "tool_calls") as a JSON string gets them decoded; what is
# still not a list of objects is a ValueError (the server's 400), not an AttributeError on m.get
from serve.frontend import anthropic_to_messages, openai_to_messages
call = [{"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{\"x\": 1}"}}]
listed = [{"role": "user", "content": "u"}, {"role": "assistant", "content": "", "tool_calls": call}]
encoded = [listed[0], dict(listed[1], tool_calls=json.dumps(call))]
want = openai_to_messages({"messages": listed})[0]
self.assertEqual(want[1]["tool_calls"], [{"function": {"name": "f", "arguments": {"x": 1}}}])
self.assertEqual(openai_to_messages({"messages": json.dumps(listed)})[0], want)
self.assertEqual(openai_to_messages({"messages": json.dumps(encoded)})[0], want)
anth = [{"role": "user", "content": "u"}]
self.assertEqual(anthropic_to_messages({"messages": json.dumps(anth)})[0],
anthropic_to_messages({"messages": anth})[0])
self.assertEqual(openai_to_messages({})[0], []) # no field: nothing, as before
self.assertEqual(openai_to_messages({"messages": None})[0], [])
for bad in ("not json", "\"a string\"", json.dumps({"role": "user"}), ["hi"], [{"role": "user"}, 3], 5,
{"role": "user", "content": "u"}):
for fn in (openai_to_messages, anthropic_to_messages):
with self.subTest(bad=bad, fn=fn.__name__):
with self.assertRaisesRegex(ValueError, "messages must be a list of objects"):
fn({"messages": bad})
for calls in (["f"], "[1]", [{"function": "f"}], "{"):
with self.subTest(calls=calls), self.assertRaisesRegex(ValueError, "tool_calls must be a list of objects"):
openai_to_messages({"messages": [{"role": "assistant", "content": "", "tool_calls": calls}]})
class SamplingKeys(unittest.TestCase):
"""The GEN line's sampling keys: top_k 0 ("off") or wider than the engine's 64 get the widest list, 64 (they used
@@ -472,6 +587,30 @@ class GpuChoice(unittest.TestCase):
self.assertEqual(plain.get("CUDA_VISIBLE_DEVICES"), os.environ.get("CUDA_VISIBLE_DEVICES"))
self.assertEqual(plain.get("CUDA_DEVICE_ORDER"), os.environ.get("CUDA_DEVICE_ORDER"))
def test_vision_device(self):
# #408: the image encoder on its own card; the engine's environment stays as it was
from serve.server import child_env, vision_env
cfg = {"gpu": [0, 1], "vision": {"exe": "v", "cuda_device": 2}}
env = child_env(cfg)
venv = vision_env(cfg, env)
self.assertEqual(venv["CUDA_VISIBLE_DEVICES"], "2")
self.assertEqual(venv["CUDA_DEVICE_ORDER"], "PCI_BUS_ID")
self.assertEqual(env["CUDA_VISIBLE_DEVICES"], "0,1")
plain = {"gpu": [0, 1], "vision": {"exe": "v"}}
self.assertIs(vision_env(plain, env), env) # no cuda_device: the engine's environment, unchanged
def test_hip_ordinal(self):
"""#325: on Windows the HIP ordinal setup resolved wins over the config's "gpu" (an iGPU takes HIP's 0)."""
from serve.server import child_env
self.assertEqual(child_env({"backend": "hip", "gpu": 1})["HIP_VISIBLE_DEVICES"], "1") # Linux: KFD order
self.assertEqual(child_env({"backend": "hip", "hip_ordinal": 1})["HIP_VISIBLE_DEVICES"], "1")
self.assertEqual(child_env({"backend": "hip", "gpu": 0, "hip_ordinal": 1})["HIP_VISIBLE_DEVICES"], "1")
self.assertEqual(child_env({"backend": "hip", "gpu": [1, 0], "hip_ordinal": 2})["HIP_VISIBLE_DEVICES"],
"1,0") # a layer split keeps its list
self.assertEqual(child_env({"backend": "hip", "gpu": 0, "hip_ordinal": "x"})["HIP_VISIBLE_DEVICES"], "0")
plain = child_env({"backend": "hip"})
self.assertEqual(plain.get("HIP_VISIBLE_DEVICES"), os.environ.get("HIP_VISIBLE_DEVICES"))
class RecordingPrompt(MockEngine):
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
@@ -501,6 +640,59 @@ class DyingEngine(MockEngine):
yield t
class SlowPromptEngine(MockEngine):
"""Reads a "long prompt" for up to 20 s without a token (the engine sends nothing then), stopping on cancel."""
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
self.cancelled_after = None
t0 = time.monotonic()
while time.monotonic() - t0 < 20:
if cancel.is_set():
self.cancelled_after = time.monotonic() - t0
return
time.sleep(0.05)
yield from super().generate(ids, max_new, sampling, cancel, embeddings)
class ClientHangUp(unittest.TestCase):
"""#430 #431: a client that hangs up during a long prompt read cancels the request within about a second -
non-streamed (which writes nothing until the end) and streamed (one keep-alive per prompt chunk) alike."""
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.engine = SlowPromptEngine(tok, "</think>\n\nOK", max_context=CTX)
cls.svc = Service(cls.engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
cls.httpd = serve(cls.svc, port=0)
cls.port = cls.httpd.server_address[1]
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
def hang_up(self, stream):
import socket as so
body = json.dumps({"model": "x", "max_tokens": 20, "stream": stream,
"messages": [{"role": "user", "content": "a long prompt"}]}).encode()
c = so.create_connection(("127.0.0.1", self.port))
c.sendall(b"POST /v1/chat/completions HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" + body)
time.sleep(1.0)
c.close()
t0 = time.monotonic()
while self.engine.cancelled_after is None and time.monotonic() - t0 < 10:
time.sleep(0.05)
self.assertIsNotNone(self.engine.cancelled_after, "the request was not cancelled")
self.assertLess(self.engine.cancelled_after, 3.0)
def test_non_streamed(self):
self.hang_up(False)
def test_streamed(self):
self.hang_up(True)
class EngineDeath(unittest.TestCase):
"""Issue #27: a dead engine is an error (not "length"), and the next request starts it again."""
@@ -564,6 +756,174 @@ class EngineDeath(unittest.TestCase):
httpd.server_close()
class DoneLineEngine(MockEngine):
"""The mock engine whose `last` comes from a DONE line, parsed as StrataEngine parses it."""
def __init__(self, *a, done_lines=(), **kw):
super().__init__(*a, **kw)
self.done_lines = list(done_lines)
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
try:
yield from super().generate(ids, max_new, sampling, cancel, embeddings)
finally:
StrataEngine._parse_done(self, self.done_lines.pop(0))
class DraftCounts(unittest.TestCase):
"""#457: GET /metrics gives each request's speculative draft counts (offered / accepted, from the engine's DONE
line; None when the line has no such fields) and their running sums in the totals."""
def test_drafts_in_history_and_totals(self):
tok = ByteTokenizer()
engine = DoneLineEngine(tok, "</think>\n\nok", max_context=CTX, done_lines=[
"DONE 4 20 40.0 30.0 stop 7 12 0", # 7 of 12 drafts accepted
"DONE 4 20 40.0 30.0 stop", # an engine that reports no drafts
"DONE 4 20 40.0 30.0 stop 3 5 0 9 10"])
svc = Service(engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
self.assertEqual((svc.totals["drafts_offered"], svc.totals["drafts_accepted"]), (0, 0))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
for _ in range(3):
body = json.dumps({"model": "m", "max_tokens": 10, "messages": [{"role": "user", "content": "hi"}]})
with urllib.request.urlopen(urllib.request.Request(base + "/v1/chat/completions", data=body.encode(),
headers={"Content-Type": "application/json"}),
timeout=30) as r:
self.assertEqual(r.status, 200)
with urllib.request.urlopen(base + "/metrics", timeout=10) as r:
m = json.loads(r.read())
finally:
httpd.shutdown()
httpd.server_close()
rows = m["requests"] # newest first
self.assertEqual([(r["drafts_offered"], r["drafts_accepted"]) for r in rows], [(5, 3), (None, None), (12, 7)])
self.assertEqual((m["totals"]["drafts_offered"], m["totals"]["drafts_accepted"]), (17, 10))
class LearnedProfile(unittest.TestCase):
"""#477: "expert_profile_save" in the config: the engine saves its learned profile there, and the next start
begins from it when it is a profile of the same model; without the key the arguments are unchanged."""
def write(self, path, nl=48, ne=512, n=4):
sys.path.insert(0, str(ROOT / "tools"))
import make_profile
old = make_profile.N_LAYER
make_profile.N_LAYER = nl
try:
make_profile.write_profile(path, [(i % nl, i // nl) for i in range(n)], n_expert=ne)
finally:
make_profile.N_LAYER = old
def test_without_the_key_nothing_changes(self):
args = ["--native", "x", "--expert-profile", "data/expert-profile.bin", "--adapt-every", "4"]
self.assertEqual(engine_args({"args": list(args)}), args)
self.assertEqual(engine_args({"args": list(args), "expert_profile_save": ""}), args)
def test_save_and_start_from_it(self):
d = Path(tempfile.mkdtemp())
self.write(d / "base.bin")
cfg = {"args": ["--expert-profile", "base.bin"], "cwd": str(d), "expert_profile_save": "learned.bin",
"expert_profile_save_every": 5}
# nothing saved yet: the config's profile, and the engine is told where to save
self.assertEqual(engine_args(cfg), ["--expert-profile", "base.bin", "--expert-profile-save", "learned.bin",
"--expert-profile-save-every", "5"])
self.write(d / "learned.bin")
self.assertEqual(engine_args(cfg)[:2], ["--expert-profile", "learned.bin"])
self.write(d / "learned.bin", ne=256) # another model's: not used
self.assertEqual(engine_args(cfg)[:2], ["--expert-profile", "base.bin"])
(d / "learned.bin").write_bytes(b"STRP" + bytes(20)) # not a whole profile
self.assertEqual(engine_args(cfg)[:2], ["--expert-profile", "base.bin"])
self.write(d / "learned.bin")
(d / "learned.bin").write_bytes((d / "learned.bin").read_bytes()[:30]) # truncated
self.assertEqual(engine_args(cfg)[:2], ["--expert-profile", "base.bin"])
def test_no_profile_in_the_args(self):
cfg = {"args": ["--native", "x"], "expert_profile_save": "learned.bin"}
self.assertEqual(engine_args(cfg), ["--native", "x", "--expert-profile-save", "learned.bin"])
class DraftHeadHint(unittest.TestCase):
"""#474: a start that stopped at "the draft head does not fit" says what to change, from this start's log lines."""
def log(self, text, before=""):
d = tempfile.mkdtemp()
p = Path(d) / "engine.log"
p.write_text(before + text, encoding="utf-8")
return str(p), len(before.encode())
def test_the_engines_hint_is_relayed(self):
p, off = self.log("strata mtp: the draft head over 106299 tokens needs 348 MiB of VRAM and 120 MiB is free.\n"
"strata mtp: hint: a smaller draft vocabulary needs less VRAM: --draft-vocab en (...)\n"
"strata serve: mtp: the draft head does not fit\n")
h = start_failure_hint(p, off)
self.assertIn("the draft head does not fit", h)
self.assertIn("348 MiB", h)
self.assertIn("--draft-vocab en", h)
def test_an_older_engine_gets_the_advice_in_words(self):
p, off = self.log("strata serve: mtp: the draft head does not fit\n")
self.assertIn("--draft-vocab en", start_failure_hint(p, off))
def test_other_failures_and_earlier_starts_add_nothing(self):
p, off = self.log("strata serve: cannot open the pack\n")
self.assertEqual(start_failure_hint(p, off), "")
# an earlier start's failure (before this start's offset) is not this one's
p, off = self.log("strata serve: cannot open the pack\n",
before="strata serve: mtp: the draft head does not fit\n")
self.assertEqual(start_failure_hint(p, off), "")
self.assertEqual(start_failure_hint(None, 0), "")
self.assertEqual(start_failure_hint(str(Path(tempfile.mkdtemp()) / "missing.log"), 0), "")
class CancelledRead(unittest.TestCase):
"""#471: a request cancelled while its prompt was read is recorded with the tokens the engine read (the DONE
line's 15th field), not the whole prompt; an older engine's line (no such field) keeps the whole prompt."""
def test_parse_done_read_field(self):
e = SimpleNamespace()
StrataEngine._parse_done(e, "DONE 0 98179 17565.0 0.0 cancel 0 0 0 0 0 0 0 0.0 12288")
self.assertEqual((e.last["prompt_tokens"], e.last["prompt_read"], e.last["finish"]), (98179, 12288, "cancel"))
StrataEngine._parse_done(e, "DONE 0 98179 17565.0 0.0 cancel 0 0 0 0 0 0 0 0.0")
self.assertNotIn("prompt_read", e.last)
def test_prompt_tokens_seen(self):
last = {"finish": "cancel", "reused": 1000, "prompt_read": 2000}
self.assertEqual(prompt_tokens_seen(98179, last), 3000)
self.assertEqual(prompt_tokens_seen(98179, {**last, "finish": "stop"}), 98179) # read in full: all of it
self.assertEqual(prompt_tokens_seen(98179, {"finish": "cancel", "reused": 0}), 98179) # an older engine
self.assertEqual(prompt_tokens_seen(98179, {}), 98179) # no DONE at all
self.assertEqual(prompt_tokens_seen(10, {**last, "prompt_read": 50}), 10) # never past the prompt
def test_history_and_totals(self):
tok = ByteTokenizer()
engine = DoneLineEngine(tok, "</think>\n\nok", max_context=CTX, done_lines=[
"DONE 0 20 400.0 0.0 cancel 0 0 0 0 0 0 0 0.0 8", # stopped after 8 prompt tokens
"DONE 4 20 40.0 30.0 stop 0 0 0 0 0 0 0 0.0 20",
"DONE 0 20 400.0 0.0 cancel 0 0 0"]) # an older engine: no read count
svc = Service(engine, tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
for _ in range(3):
body = json.dumps({"model": "m", "max_tokens": 10, "messages": [{"role": "user", "content": "hi"}]})
with urllib.request.urlopen(urllib.request.Request(base + "/v1/chat/completions", data=body.encode(),
headers={"Content-Type": "application/json"}),
timeout=30) as r:
self.assertEqual(r.status, 200)
with urllib.request.urlopen(base + "/metrics", timeout=10) as r:
m = json.loads(r.read())
finally:
httpd.shutdown()
httpd.server_close()
rows = m["requests"][::-1] # oldest first
total = rows[0]["prompt_total"]
self.assertGreater(total, 8)
self.assertEqual([r["prompt_total"] for r in rows], [total] * 3)
self.assertEqual([(r["prompt_tokens"], r["prompt_read"]) for r in rows], [(8, 8), (total, 20), (total, None)])
self.assertEqual(m["totals"]["prompt_tokens"], 8 + 2 * total)
class LiveRate(unittest.TestCase):
"""The Monitor's Speed readout: live.tok_s is a rate, and a request that never got a DONE keeps no counters.
@@ -783,6 +1143,29 @@ class SharedSettings(unittest.TestCase):
finally:
self.svc.api_key = ""
def test_proxy_headers_do_not_make_a_page_strata_s_own(self):
# #321: X-Forwarded-*, CF-Ray or CF-Connecting-IP say nothing about the page that sent the request - any web
# page behind any proxy would otherwise change the settings (or run MCP tools)
for extra in ({"X-Forwarded-Host": "proxy.example.com"}, {"CF-Ray": "1234567890"},
{"X-Forwarded-For": "203.0.113.9"}, {"CF-Connecting-IP": "203.0.113.9"}):
code, _ = self.req("/settings", {"defaults": {"temperature": 1}},
{"Origin": "https://proxy.example.com", **extra})
self.assertEqual(code, 403, extra)
# another port on the same host is another site (a local dev server's page)
code, _ = self.req("/settings", {"defaults": {"temperature": 1}}, {"Origin": "http://127.0.0.1:1"})
self.assertEqual(code, 403)
def test_a_trusted_origin_is_strata_s_own_page(self):
# the web app behind a reverse proxy or tunnel: the config's trusted_origins
self.svc.trusted_origins = ["https://strata.example.com"]
try:
code, _ = self.req("/settings", {"defaults": {}}, {"Origin": "https://strata.example.com"})
self.assertEqual(code, 200)
code, _ = self.req("/settings", {"defaults": {}}, {"Origin": "https://evil.example.com"})
self.assertEqual(code, 403)
finally:
self.svc.trusted_origins = []
class WebApp(unittest.TestCase):
"""The web app (PR #22's dashboard idea, rebuilt): its page and files, and GET /metrics."""
@@ -976,6 +1359,58 @@ class UsageAndStatus(unittest.TestCase):
return [json.loads(line[6:]) for line in raw.decode().splitlines()
if line.startswith("data: {")]
def preflight(self, path, origin="https://chat.example.com"):
req = urllib.request.Request(self.base + path, method="OPTIONS",
headers={"Origin": origin, "Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "authorization, content-type"})
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, r.headers
def test_cors_is_off_by_default(self):
# #321: OPTIONS is answered, but without cors_origins no page of another origin is let in
status, h = self.preflight("/v1/chat/completions")
self.assertEqual(status, 204)
self.assertIsNone(h.get("Access-Control-Allow-Origin"))
with urllib.request.urlopen(self.base + "/health", timeout=10) as r:
self.assertIsNone(r.headers.get("Access-Control-Allow-Origin"))
def test_cors_for_the_configured_origins_on_the_api_only(self):
self.svc.cors_origins = ["https://chat.example.com"]
try:
status, h = self.preflight("/v1/chat/completions")
self.assertEqual((status, h.get("Access-Control-Allow-Origin")), (204, "https://chat.example.com"))
self.assertIn("OPTIONS", h.get("Access-Control-Allow-Methods", ""))
self.assertIn("authorization", h.get("Access-Control-Allow-Headers", ""))
self.assertIsNone(self.preflight("/v1/chat/completions", "https://evil.example.com")[1]
.get("Access-Control-Allow-Origin"))
# never on the app's own endpoints (/settings, /unload, ...)
self.assertIsNone(self.preflight("/settings")[1].get("Access-Control-Allow-Origin"))
req = urllib.request.Request(self.base + "/v1/models", headers={"Origin": "https://chat.example.com"})
with urllib.request.urlopen(req, timeout=10) as r:
self.assertEqual(r.headers.get("Access-Control-Allow-Origin"), "https://chat.example.com")
self.svc.cors_origins = ["*"]
self.assertEqual(self.preflight("/v1/messages", "https://any.example.com")[1]
.get("Access-Control-Allow-Origin"), "*")
finally:
self.svc.cors_origins = []
def test_sse_is_not_buffered_by_proxies(self):
req = urllib.request.Request(self.base + "/v1/chat/completions", headers={"Content-Type": "application/json"},
data=json.dumps({"model": "m", "stream": True,
"messages": [{"role": "user", "content": "hi"}]}).encode())
with urllib.request.urlopen(req, timeout=30) as r:
self.assertEqual(r.headers.get("X-Accel-Buffering"), "no")
r.read()
def test_origin_lists_are_checked(self):
from serve.server import origins_of
self.assertEqual(origins_of(None, "k", True), [])
self.assertEqual(origins_of("https://a.example.com/", "k", False), ["https://a.example.com"])
self.assertEqual(origins_of(["*", "http://localhost:3000"], "k", True), ["*", "http://localhost:3000"])
for bad in ("*", "a.example.com", "https://a.example.com/path", "https://*.example.com", 5):
with self.assertRaises(SystemExit):
origins_of(bad, "k", False)
def test_openai(self):
b = self.chat("/v1/chat/completions")
u, t = b["usage"], b["timings"]
@@ -1380,5 +1815,233 @@ class StatusHandover(unittest.TestCase):
self.assertFalse(svc.status["busy"])
self.assertNotIn("tail", svc.status)
FAKE_STRATA = '''import pathlib, sys, time
gate = pathlib.Path(sys.argv[sys.argv.index("--gate") + 1])
print("INFO engine=0.0.0", flush=True)
while not gate.exists(): # the test says when the engine is "ready"
time.sleep(0.01)
print("READY 4096 stop", flush=True)
for line in sys.stdin:
if line.startswith("QUIT"):
break
'''
class RestartWindow(unittest.TestCase):
"""#344: while the engine restarts it is not alive (a request waits for the restart instead of reading
max_context 0), and a request that still meets max_context 0 gets a 503 "starting", not a 400 about its prompt."""
def test_not_alive_until_ready(self):
from unittest import mock
import serve.server as server
with tempfile.TemporaryDirectory() as d:
script, gate = Path(d) / "fake_strata.py", Path(d) / "ready"
script.write_text(FAKE_STRATA, encoding="utf-8")
real = server.subprocess.Popen
with mock.patch.object(server.subprocess, "Popen",
lambda cmd, **kw: real([sys.executable, str(script), *cmd[1:]], **kw)):
gate.touch()
eng = StrataEngine("strata", ["--gate", str(gate)])
try:
self.assertEqual(eng.max_context, 4096)
self.assertTrue(eng.alive())
gate.unlink()
old = eng.proc
old.kill()
old.wait(10)
deadline = time.time() + 10
while not getattr(eng, "ended", False) and time.time() < deadline:
time.sleep(0.01) # the server has noticed: its output closed
self.assertFalse(eng.alive())
t = threading.Thread(target=eng.restart)
t.start()
deadline = time.time() + 10
while eng.proc is old and time.time() < deadline:
time.sleep(0.005)
self.assertIsNot(eng.proc, old, "restart never started the engine")
time.sleep(0.2) # the new engine is up, but has not said READY
self.assertEqual(eng.max_context, 0)
self.assertFalse(eng.alive(), "alive before READY: a request would plan with context 0")
gate.touch()
t.join(10)
self.assertFalse(t.is_alive())
self.assertTrue(eng.alive())
self.assertEqual(eng.max_context, 4096)
# restart() of a running engine kills it first: that engine's output thread ends after the new
# one is up, and must not mark it dead (it did: every request after restarted it again)
eng.restart()
time.sleep(0.5)
self.assertTrue(eng.alive())
self.assertEqual(eng.max_context, 4096)
finally:
gate.touch()
eng.unload()
def test_context_zero_is_503(self):
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "</think>\n\nok", max_context=0), tok,
ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
for path, body in (("/v1/chat/completions", {"model": "m", "messages": [{"role": "user", "content": "hi"}],
"max_tokens": 16}),
("/v1/messages", {"model": "m", "max_tokens": 16,
"messages": [{"role": "user", "content": "hi"}]})):
req = urllib.request.Request(base + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with self.assertRaises(urllib.error.HTTPError) as cm:
urllib.request.urlopen(req, timeout=30)
with cm.exception as e:
self.assertEqual(e.code, 503, path)
text = e.read().decode()
self.assertIn("starting", text)
self.assertNotIn("leaves no room", text)
finally:
httpd.shutdown()
httpd.server_close()
class ModelAliases(unittest.TestCase):
"""#297: the config's `aliases` are listed by /v1/models and accepted as model names (answered under that name)."""
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.svc = Service(MockEngine(tok, "</think>\n\nok", max_context=CTX), tok,
ChatTemplate(ROOT / "serve/chat_template.jinja"), model_name="qwen3.8-flash-next-iq3_xxs")
cls.svc.set_aliases(["qwen", "local-model", "qwen", " ", "qwen3.8-flash-next-iq3_xxs"])
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 get(self, path):
with urllib.request.urlopen(self.base + path, timeout=30) as r:
return json.loads(r.read())
def post(self, path, body):
req = urllib.request.Request(self.base + path, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode()
def test_set_aliases(self):
self.assertEqual(self.svc.aliases, ["qwen", "local-model"]) # duplicates, blanks and the name itself
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "x", max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
svc.set_aliases("a, b")
self.assertEqual(svc.aliases, ["a", "b"])
svc.set_aliases(None)
self.assertEqual(svc.aliases, [])
for bad in (3, ["a", 1], {"a": 1}):
with self.assertRaises(ValueError):
svc.set_aliases(bad)
def test_models_lists_them(self):
data = self.get("/v1/models")["data"]
self.assertEqual([m["id"] for m in data], ["qwen3.8-flash-next-iq3_xxs", "qwen", "local-model"])
self.assertEqual(data[0]["aliases"], ["qwen", "local-model"])
self.assertEqual(data[1]["alias_of"], "qwen3.8-flash-next-iq3_xxs")
self.assertEqual(self.get("/props?model=local-model")["model_alias"], "qwen3.8-flash-next-iq3_xxs")
def test_requests_are_answered_under_the_alias(self):
msgs = [{"role": "user", "content": "hi"}]
for asked, want in (("qwen", "qwen"), ("local-model", "local-model"),
("qwen3.8-flash-next-iq3_xxs", "qwen3.8-flash-next-iq3_xxs"),
("something-else", "qwen3.8-flash-next-iq3_xxs")): # still served, as before
with self.subTest(asked=asked):
out = json.loads(self.post("/v1/chat/completions", {"model": asked, "messages": msgs, "max_tokens": 8}))
self.assertEqual(out["model"], want)
text = self.post("/v1/chat/completions", {"model": asked, "messages": msgs, "max_tokens": 8,
"stream": True})
first = json.loads(text.split("data: ", 2)[1].strip())
self.assertEqual(first["model"], want)
out = json.loads(self.post("/v1/messages", {"model": asked, "messages": msgs, "max_tokens": 8}))
self.assertEqual(out["model"], want)
def test_without_aliases_nothing_changes(self):
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "x", max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
try:
with urllib.request.urlopen(f"http://127.0.0.1:{httpd.server_address[1]}/v1/models", timeout=30) as r:
data = json.loads(r.read())["data"]
self.assertEqual(len(data), 1)
self.assertNotIn("aliases", data[0])
self.assertEqual(svc.model_for({"model": "x"}), svc.model)
finally:
httpd.shutdown()
httpd.server_close()
class AmdTelemetry(unittest.TestCase):
"""#301: the AMD backend's readings from a fake amdgpu sysfs tree: KFD node -> render node, as setup numbers the
cards (the CPU node skipped), and free_vram_mib on HIP."""
def tree(self, d):
nodes = Path(d) / "class/kfd/kfd/topology/nodes"
for n, props in ((0, "cpu_cores_count 16\nsimd_count 0\ngfx_target_version 0\ndrm_render_minor 0\n"),
(1, "simd_count 128\ngfx_target_version 120001\ndrm_render_minor 129\n"),
(2, "simd_count 128\ngfx_target_version 120001\ndrm_render_minor 128\n")):
(nodes / str(n)).mkdir(parents=True)
(nodes / str(n) / "properties").write_text(props)
for minor, used, busy, temp, power in ((129, 2 << 30, 37, 51000, 85000000), (128, 6 << 30, 99, 64000, None)):
dev = Path(d) / f"class/drm/renderD{minor}/device"
hw = dev / "hwmon" / "hwmon4"
hw.mkdir(parents=True)
(dev / "gpu_busy_percent").write_text(f"{busy}\n")
(dev / "mem_info_vram_used").write_text(f"{used}\n")
(dev / "mem_info_vram_total").write_text(f"{32 << 30}\n")
(dev / "product_name").write_text("AMD Radeon AI PRO R9700\n")
(hw / "temp1_input").write_text(f"{temp}\n")
if power is not None:
(hw / "power1_average").write_text(f"{power}\n")
else:
(hw / "power1_input").write_text("120000000\n")
(hw / "power1_cap").write_text("300000000\n")
def test_readings(self):
from serve import telemetry
with tempfile.TemporaryDirectory() as d:
self.tree(d)
with mock.patch.object(telemetry, "SYSFS", d):
self.assertTrue(telemetry.amd_device_dir(0).endswith(os.path.join("renderD129", "device")))
self.assertTrue(telemetry.amd_device_dir(1).endswith(os.path.join("renderD128", "device")))
self.assertIsNone(telemetry.amd_device_dir(2))
g = telemetry.gpu_reader(0, amd=True)
self.assertTrue(g.ok())
self.assertEqual(g.name(), "AMD Radeon AI PRO R9700")
self.assertEqual(g.read(), {"util": 37, "mem_used": 2 << 30, "mem_total": 32 << 30, "temp": 51.0,
"power": 85.0, "power_limit": 300.0})
r = telemetry.gpu_reader(1, amd=True).read()
self.assertEqual((r["util"], r["temp"], r["power"]), (99, 64.0, 120.0)) # power1_input
self.assertEqual(telemetry.free_vram_mib(0, amd=True), 30 << 10)
self.assertIsNone(telemetry.free_vram_mib(5, amd=True))
t = telemetry.Telemetry(gpu_index=0, gpu_indices=[0, 1], amd=True)
s = t.sample()
self.assertEqual(t.static["gpu_name"], "AMD Radeon AI PRO R9700 + AMD Radeon AI PRO R9700")
self.assertEqual((s["gpu_mem_used"], s["gpu_util"], s["gpu_temp"], s["gpu_power"]),
(8 << 30, 68.0, 64.0, 205.0))
with tempfile.TemporaryDirectory() as d: # no amdgpu: nothing, and nothing breaks
with mock.patch.object(telemetry, "SYSFS", d):
self.assertFalse(telemetry.gpu_reader(0, amd=True).ok())
self.assertIsNone(telemetry.free_vram_mib(0, amd=True))
def test_free_vram_on_hip(self):
from serve import telemetry
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "x", max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
svc.backend, svc.gpu_index = "hip", 1
with tempfile.TemporaryDirectory() as d:
self.tree(d)
with mock.patch.object(telemetry, "SYSFS", d):
self.assertEqual(svc.free_vram_mib(), 26 << 10)
if __name__ == "__main__":
unittest.main()
+167
View File
@@ -0,0 +1,167 @@
"""Exercise the JSON contract through HTTP, including H3's strict Story shape."""
import json
import unittest
import urllib.error
import urllib.request
from pathlib import Path
from unittest import mock
from serve.frontend import ChatTemplate
from serve import structured
from serve.server import ByteTokenizer, MockEngine, Service, serve
try:
import jsonschema # noqa: F401
HAVE_JSONSCHEMA = True
except ImportError: # optional: json_schema answers are then only checked to be one object
HAVE_JSONSCHEMA = False
STORY = {"title": "Photobooth", "sheet": "A single continuous shot.", "cast": [],
"boxes": [{"box": 1, "duration_ms": 15000, "new_shot": True, "beat": "Hailee changes poses.",
"on_screen": ["Hailee"], "references": [], "ends": "She smiles."}], "suggestions": []}
def object_schema(properties):
return {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False}
TEXT = {"type": "string"}
STORY_SCHEMA = object_schema({
"title": TEXT, "sheet": TEXT, "cast": {"type": "array", "items": object_schema({"name": TEXT,
"references": {"type": "array", "items": TEXT, "maxItems": 0}})},
"boxes": {"type": "array", "minItems": 1, "maxItems": 1, "items": object_schema({
"box": {"type": "integer", "minimum": 1, "maximum": 1},
"duration_ms": {"type": "integer", "minimum": 1, "maximum": 15000}, "new_shot": {"type": "boolean"},
"beat": TEXT, "on_screen": {"type": "array", "items": TEXT},
"references": {"type": "array", "items": TEXT, "maxItems": 0}, "ends": TEXT})},
"suggestions": {"type": "array", "items": object_schema({"what": TEXT, "why": TEXT,
"boxes": {"type": "array", "items": {"type": "integer"}}, "description": TEXT})}})
FORMAT = {"type": "json_schema", "json_schema": {"name": "h3_story", "strict": True, "schema": STORY_SCHEMA}}
class Structured(unittest.TestCase):
def request(self, path, body=None, headers=None):
req = urllib.request.Request(self.base + path, data=json.dumps(body).encode() if body is not None else None,
headers={"Content-Type": "application/json", **(headers or {})})
try:
response = urllib.request.urlopen(req, timeout=10)
except urllib.error.HTTPError as error:
response = error
with response:
data = response.read().decode()
return response.status, data if "event-stream" in response.headers.get("Content-Type", "") else json.loads(data)
def setUp(self):
self.tok = ByteTokenizer()
self.engine = MockEngine(self.tok, json.dumps(STORY), max_context=16384)
self.svc = Service(self.engine, self.tok, ChatTemplate(Path(__file__).parent / "chat_template.jinja"))
self.httpd = serve(self.svc, port=0)
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
def tearDown(self):
self.httpd.shutdown()
self.httpd.server_close()
def chat(self, script=None, **kwargs):
if script is not None:
self.engine = self.svc.engine = MockEngine(self.tok, script, max_context=16384)
req = {"messages": [{"role": "system", "content": "Write a film plan."},
{"role": "user", "content": "Photobooth. 15 seconds."}],
"response_format": FORMAT, "reasoning_effort": "none", **kwargs}
return self.request("/v1/chat/completions", req)
def test_h3_schema_success_and_stream_with_reasoning(self):
code, reply = self.chat()
self.assertEqual(code, 200)
self.assertEqual(json.loads(reply["choices"][0]["message"]["content"]), STORY)
prompt = self.tok.decode(self.engine.last_prompt)
self.assertIn("OUTPUT FORMAT REQUIREMENT", prompt)
self.assertIn('"duration_ms"', prompt)
code, raw = self.chat("Consider the shot.</think>\n" + json.dumps(STORY), stream=True, reasoning_effort="low")
self.assertEqual(code, 200)
chunks = [json.loads(line[6:]) for line in raw.splitlines() if line.startswith("data: {")]
content = "".join(c["choices"][0]["delta"].get("content", "") for c in chunks)
self.assertEqual(json.loads(content), STORY)
self.assertIn("reasoning_content", raw)
self.assertIn("data: [DONE]", raw)
@unittest.skipUnless(HAVE_JSONSCHEMA, "jsonschema is not installed")
def test_invalid_generation_is_an_error_and_never_streams_content(self):
for script in ("## Film Plan - Photobooth", '{"title":"Only a title"}',
json.dumps({**STORY, "extra": True}),
json.dumps({**STORY, "boxes": [{**STORY["boxes"][0], "duration_ms": "15000"}]})):
with self.subTest(script=script):
code, reply = self.chat(script)
self.assertEqual(code, 502)
self.assertEqual(reply["error"]["code"], "structured_output_failed")
_, raw = self.chat(script, stream=True)
self.assertIn('"structured_output_failed"', raw)
self.assertNotIn('"delta"', raw)
self.assertIn("data: [DONE]", raw)
def test_object_mode_strict_json_and_truncation(self):
fmt = {"type": "json_object"}
self.assertEqual(self.chat('{"answer":4}', response_format=fmt)[0], 200)
for script in ('[]', '{"x":NaN}', '{"x":1,"x":2}', '{"x":1e999}', '```json\n{}\n```'):
self.assertEqual(self.chat(script, response_format=fmt)[0], 502)
self.assertEqual(self.chat('{"answer":4}', response_format=fmt, max_tokens=5)[0], 502)
# Plain text callers retain normal streaming, without a JSON validation gate.
self.assertEqual(self.chat("Prose.", response_format={"type": "text"})[0], 200)
def test_rejected_schema_does_not_load_the_engine_or_retry(self):
with mock.patch.object(self.svc, "load") as load:
code, _ = self.chat(response_format={"type": "json_schema"})
self.assertEqual(code, 400)
load.assert_not_called()
self.engine = self.svc.engine = MockEngine(self.tok, "Markdown is not JSON", max_context=16384)
with mock.patch.object(self.engine, "generate", wraps=self.engine.generate) as generate:
self.assertEqual(self.chat()[0], 502)
self.assertEqual(generate.call_count, 1)
@unittest.skipUnless(HAVE_JSONSCHEMA, "jsonschema is not installed")
def test_invalid_schema_is_400_and_local_refs_work(self):
for schema in ({"type": "object", "properties": {"x": {"type": "invalid"}}},
{"type": "object", "$ref": "https://other.example/schema"}):
fmt = {"type": "json_schema", "json_schema": {"name": "test", "strict": True, "schema": schema}}
self.assertEqual(self.chat(response_format=fmt)[0], 400)
schema = object_schema({"answer": {"$ref": "#/$defs/n"}})
schema["$defs"] = {"n": {"type": "integer"}}
fmt = {"type": "json_schema", "json_schema": {"name": "local", "schema": schema}}
self.assertEqual(self.chat('{"answer":4}', response_format=fmt)[0], 200)
def test_malformed_formats_and_tools_are_rejected_before_loading(self):
formats = ([], {"type": "unsupported"},
{"type": "json_schema", "json_schema": {"schema": {"type": "object"}}},
{"type": "json_schema", "json_schema": {"name": "x", "strict": "true", "schema": {"type": "object"}}})
with mock.patch.object(self.svc, "load") as load:
for fmt in formats:
with self.subTest(fmt=fmt):
self.assertEqual(self.chat(response_format=fmt)[0], 400)
self.assertEqual(self.chat(tools=[{"type": "function", "function": {"name": "search", "parameters": {"type": "object"}}}])[0], 400)
self.assertEqual(self.chat(strata_mcp=True)[0], 400)
self.assertEqual(self.request("/v1/chat/completions", [])[0], 400)
load.assert_not_called()
def test_without_jsonschema_an_object_is_still_required(self):
# jsonschema is optional: without it json_schema answers are checked to be one JSON object, json_object
# never needs it, and nothing is imported at server start
with mock.patch.object(structured, "_jsonschema", False):
self.assertEqual(self.chat()[0], 200)
for script in ("## Film Plan - Photobooth", "[1, 2]"):
code, reply = self.chat(script)
self.assertEqual(code, 502, script)
self.assertEqual(reply["error"]["code"], "structured_output_failed")
self.assertEqual(self.chat('{"answer":4}', response_format={"type": "json_object"})[0], 200)
def test_the_server_does_not_import_jsonschema_at_start(self):
import subprocess
import sys
code = ("import sys; import serve.server; "
"sys.exit(1 if any(m == 'jsonschema' or m.startswith('jsonschema.') for m in sys.modules) else 0)")
r = subprocess.run([sys.executable, "-c", code], cwd=str(Path(__file__).resolve().parents[1]))
self.assertEqual(r.returncode, 0)
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Strata API Monitor</title>
<link rel="stylesheet" href="/web/tokens.css">
<style>
*{box-sizing:border-box}body{margin:0;background:var(--st-bg);color:var(--st-ink);font:15px/1.5 var(--st-font)}
button,input,select{font:inherit}button{cursor:pointer;color:inherit;background:var(--st-surface-2);border:1px solid var(--st-line);border-radius:9px;padding:8px 13px}button:hover{border-color:var(--st-accent)}button:disabled{opacity:.4;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid var(--st-focus);outline-offset:3px}[hidden]{display:none!important}
header{height:70px;padding:0 32px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--st-line);background:var(--st-surface)}.brand{font-size:23px;font-weight:700;letter-spacing:-.5px}.brand span{font-size:13px;font-weight:400;margin-left:14px;color:var(--st-ink-muted)}main{max-width:1560px;margin:auto;padding:28px 32px}.top{display:flex;align-items:center;justify-content:space-between;gap:24px}h1{margin:0;font-size:29px;letter-spacing:-.7px}h2{font-size:17px;margin:0}.sub,.muted{color:var(--st-ink-muted)}.sub{margin:4px 0 0}.controls{display:flex;gap:8px;flex-wrap:wrap}.primary{background:var(--st-accent);color:var(--st-accent-ink);border-color:transparent}.notice{margin-top:16px;padding:10px 14px;background:var(--st-danger-tint);color:var(--st-danger-text);border-radius:9px}.cards{display:grid;grid-template-columns:2fr repeat(3,1fr);gap:14px;margin:24px 0}.card{padding:18px 20px;border:1px solid var(--st-line);border-radius:13px;background:var(--st-surface)}.label{font-size:12px;color:var(--st-ink-muted);text-transform:uppercase;letter-spacing:.8px}.value{font-size:28px;font-weight:500;line-height:1.4;margin-top:3px}.model{font:12px var(--st-font-mono);overflow-wrap:anywhere;margin-top:4px;color:var(--st-ink-muted)}.dot{display:inline-block;width:9px;height:9px;border-radius:100%;background:var(--st-ink-muted);margin-right:9px}.loaded .dot{background:var(--st-accent)}.busy .dot{background:var(--st-warn)}.panel{border:1px solid var(--st-line);border-radius:14px;background:var(--st-surface);overflow:hidden}.panel-bar{padding:15px 20px;border-bottom:1px solid var(--st-line);display:flex;align-items:center;justify-content:space-between;gap:12px}.filters{display:flex;gap:8px}input,select{color:inherit;border:1px solid var(--st-line);background:var(--st-bg);padding:7px 10px;border-radius:8px}#search{width:190px}.workspace{display:grid;grid-template-columns:minmax(340px,38%) minmax(0,1fr);min-height:480px}.requests{border-right:1px solid var(--st-line);max-height:650px;overflow:auto}.request{display:block;width:100%;border:0;border-bottom:1px solid var(--st-line-soft);border-radius:0;padding:15px 20px;text-align:left;background:transparent}.request.selected{background:var(--st-accent-tint);box-shadow:inset 3px 0 var(--st-accent)}.request:hover{background:var(--st-surface-2)}.row{display:flex;justify-content:space-between;gap:12px}.endpoint{font:12px var(--st-font-mono);margin:8px 0 5px;overflow-wrap:anywhere}.tag{font-size:11px;border-radius:5px;padding:2px 6px;background:var(--st-surface-2);color:var(--st-ink-muted)}.tag.completed{color:var(--st-accent-text);background:var(--st-accent-tint)}.tag.error{color:var(--st-danger-text);background:var(--st-danger-tint)}.tag.loading,.tag.generating,.tag.queued{color:var(--st-warn-text);background:var(--st-warn-tint)}.small{font-size:12px;color:var(--st-ink-muted)}.detail{min-width:0;padding:20px}.empty{padding:70px 24px;text-align:center;color:var(--st-ink-muted)}.empty strong{display:block;color:var(--st-ink);font-size:18px;margin-bottom:6px}.detail-title{font:13px var(--st-font-mono)}.facts{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:18px 0}.fact{background:var(--st-bg);border-radius:8px;padding:9px 11px}.fact span{font-size:11px;color:var(--st-ink-muted);display:block}.fact b{font-weight:500;font-size:16px}.tabs{display:flex;align-items:center;gap:5px;border-bottom:1px solid var(--st-line);padding-bottom:10px}.tabs button{padding:6px 10px;border-color:transparent;background:transparent;font-size:13px}.tabs button[aria-selected=true]{background:var(--st-surface-2);color:var(--st-accent-text)}.tabs #copy{margin-left:auto;border-color:var(--st-line)}pre{margin:14px 0 0;padding:15px;background:var(--st-bg);border:1px solid var(--st-line-soft);border-radius:9px;font:12px/1.6 var(--st-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;max-height:400px;overflow:auto;min-height:180px}.detail-error{margin:12px 0;padding:10px 12px;border-radius:8px;background:var(--st-danger-tint);color:var(--st-danger-text);font-size:12px;overflow-wrap:anywhere}footer{margin-top:14px;font-size:12px;color:var(--st-ink-muted);display:flex;justify-content:space-between;gap:20px}#auth{margin-top:16px;padding:14px;display:flex;gap:10px;align-items:center}#auth input{flex:1;max-width:400px}
@media(max-width:950px){.cards{grid-template-columns:repeat(2,1fr)}.workspace{grid-template-columns:1fr}.requests{border-right:0;border-bottom:1px solid var(--st-line);max-height:260px}.top{align-items:flex-start}.brand span{display:none}}@media(max-width:600px){main{padding:20px 14px}header{padding:0 16px}.top{flex-direction:column;gap:14px}.cards{gap:8px}.card{padding:12px}.value{font-size:23px}.panel-bar{align-items:flex-start;flex-direction:column}#search{width:160px}.facts{grid-template-columns:repeat(2,1fr)}footer{flex-direction:column;gap:4px}}
</style>
<script defer src="/web/monitor.js"></script>
</head>
<body>
<header><div class="brand">Strata <span>Local inference</span></div><div class="controls"><button id="key">API key</button><button id="theme" aria-label="Switch color theme">Light / dark</button></div></header>
<main>
<div class="top"><div><h1>API Monitor</h1><p class="sub">Model status and every request, in one place.</p></div><div class="controls"><button id="load" class="primary" disabled>Load model</button><button id="unload" disabled>Unload model</button></div></div>
<div id="error" class="notice" role="status" hidden></div>
<form id="auth" class="panel" hidden><label for="api-key">API key</label><input id="api-key" type="password" autocomplete="off" placeholder="Only needed if the server has a key"><button class="primary">Connect</button></form>
<div class="cards">
<div class="card"><div class="label">Model</div><div id="model-state" class="value"><span class="dot"></span><span id="loaded">Connecting…</span></div><div id="model" class="model">127.0.0.1:5001/v1</div></div>
<div class="card"><div class="label">Requests retained</div><div id="count" class="value">—</div><div id="active" class="small">Waiting for server</div></div>
<div class="card"><div class="label">Last wall-clock</div><div id="wall" class="value">—</div><div class="small">Includes queue and model loading</div></div>
<div class="card"><div class="label">Last decode speed</div><div id="speed" class="value">—</div><div class="small">Engine timing · tokens / second</div></div>
</div>
<section class="panel"><div class="panel-bar"><h2>Requests</h2><div class="filters"><input id="search" aria-label="Filter requests" placeholder="Search ID or endpoint"><select id="filter" aria-label="Request status"><option value="all">All statuses</option><option value="active">Active</option><option value="completed">Completed</option><option value="error">Errors</option></select></div></div>
<div class="workspace"><div id="requests" class="requests" aria-label="Request list"></div><div class="detail">
<div id="no-selection" class="empty"><strong>Inspect a request</strong>Select a request to see its input, output, and timings.</div>
<div id="selection" hidden><div class="row"><div id="detail-title" class="detail-title"></div><span id="detail-status" class="tag"></span></div><div id="detail-time" class="small"></div>
<div class="facts"><div class="fact"><span>Wall-clock</span><b id="detail-wall">—</b></div><div class="fact"><span>Model load</span><b id="detail-load">—</b></div><div class="fact"><span>Queue wait</span><b id="detail-queue">—</b></div><div class="fact"><span>First token</span><b id="detail-first">—</b></div><div class="fact"><span>Prompt tokens</span><b id="detail-prompt">—</b></div><div class="fact"><span>Output tokens</span><b id="detail-tokens">—</b></div><div class="fact"><span>Decode speed</span><b id="detail-speed">—</b></div><div class="fact"><span>Response format</span><b id="detail-format">—</b></div></div>
<div id="detail-error" class="detail-error" hidden></div><div class="tabs" role="tablist" aria-label="Request content"><button role="tab" data-pane="input" aria-selected="true">Input</button><button role="tab" data-pane="output" aria-selected="false">Output</button><button role="tab" data-pane="reasoning" aria-selected="false">Reasoning</button><button role="tab" data-pane="response" aria-selected="false">API response</button><button id="copy">Copy</button></div>
<div id="content-note" class="small" style="margin-top:10px"></div><pre id="content" role="tabpanel" aria-label="Request content"></pre></div>
</div></div></section>
<footer><span>Last 100 requests kept in memory until restart. Inputs and outputs stay on this machine.</span><span id="updated">Connecting…</span></footer>
</main>
</body></html>
+111
View File
@@ -0,0 +1,111 @@
"use strict";
const $ = id => document.getElementById(id);
let selected = null, detail = null, pane = "input", records = [], state = null, operating = false, stamp = "", actionError = "";
let apiKey = sessionStorage.getItem("strata.monitor.key") || "";
document.documentElement.dataset.theme = localStorage.getItem("strata.theme") || "dark";
const seconds = n => typeof n === "number" ? `${n.toFixed(2)} s` : "—";
const speed = n => typeof n === "number" ? n.toFixed(1) : "—";
const active = r => !["completed", "error", "disconnected"].includes(r.state);
function text(id, value) { const el = $(id); if (el.textContent !== String(value)) el.textContent = value; }
async function api(path, body) {
const response = await fetch(path, {cache:"no-store", headers:{...(apiKey ? {Authorization:`Bearer ${apiKey}`} : {}), ...(body !== undefined ? {"Content-Type":"application/json"} : {})}, ...(body !== undefined ? {method:"POST", body:JSON.stringify(body)} : {})});
const value = await response.json();
if (!response.ok) {
if (response.status === 401) $("auth").hidden = false;
throw new Error(value.error?.message || `HTTP ${response.status}`);
}
return value;
}
function notice(message) { $("error").hidden = !message; text("error", message || ""); }
function list() {
const query = $("search").value.toLowerCase(), filter = $("filter").value;
const visible = records.filter(r => `${r.id} ${r.path} ${r.model}`.toLowerCase().includes(query) && (filter === "all" || (filter === "active" ? active(r) : r.state === filter)));
const nextStamp = JSON.stringify([visible, selected]);
if (stamp === nextStamp) return;
stamp = nextStamp;
const focusId = document.activeElement?.dataset.request;
$("requests").replaceChildren();
if (!visible.length) {
const empty = document.createElement("div"); empty.className = "empty";
empty.textContent = records.length ? "No matching requests." : "No requests yet. API calls will appear here automatically.";
$("requests").append(empty);
}
for (const r of visible) {
const button = document.createElement("button"); button.className = `request${selected === r.id ? " selected" : ""}`;
button.dataset.request = r.id; button.setAttribute("aria-label", `Request ${r.id}`); button.setAttribute("aria-pressed", String(selected === r.id));
const row = document.createElement("div"); row.className = "row";
const time = document.createElement("span"); time.textContent = new Date(r.started_at * 1000).toLocaleTimeString();
const tag = document.createElement("span"); tag.className = `tag ${r.state}`; tag.textContent = r.state;
row.append(time, tag);
const endpoint = document.createElement("div"); endpoint.className = "endpoint"; endpoint.textContent = `POST ${r.path}`;
const meta = document.createElement("div"); meta.className = "small";
meta.textContent = `${r.id} · ${seconds(r.wallclock_s)} · ${r.stream ? "stream" : "JSON response"}${r.http_status ? ` · HTTP ${r.http_status}` : ""}`;
button.append(row, endpoint, meta);
button.onclick = async () => { selected = r.id; detail = null; list(); await showDetail(); };
$("requests").append(button);
if (focusId === r.id) button.focus({preventScroll:true});
}
}
function content() {
if (!detail) return;
let value = detail[pane] || (pane === "response" && detail.stream ? "Streaming response: see Output and the request error, if any." : "No content.");
if (pane === "output") { try { value = JSON.stringify(JSON.parse(value), null, 2); } catch (_) {} }
text("content", value);
const note = pane === "output" && detail.error ? "Raw model output retained for diagnosis. The API returned an error." : pane === "input" ? "Original request body." : pane === "output" ? "Model answer. JSON is formatted here for readability." : pane === "reasoning" ? "Separate reasoning content, when enabled." : "API response body.";
text("content-note", note + (detail[`${pane}_truncated`] ? " Monitor capture truncated at 256K characters; the API response was not shortened." : ""));
}
async function showDetail() {
if (!selected) return;
const id = selected;
const value = await api(`/api/requests?id=${encodeURIComponent(id)}`);
if (selected !== id) return;
detail = value; $("selection").hidden = false; $("no-selection").hidden = true;
text("detail-title", `${value.id} · ${value.path}`);
text("detail-status", value.state); $("detail-status").className = `tag ${value.state}`;
text("detail-time", new Date(value.started_at * 1000).toLocaleString());
text("detail-wall", seconds(value.wallclock_s ?? ((Date.now() / 1000) - value.started_at)));
text("detail-load", seconds(value.load_s)); text("detail-queue", seconds(value.queue_s)); text("detail-first", seconds(value.first_token_s));
text("detail-prompt", value.usage?.prompt_tokens ?? value.usage?.input_tokens ?? "—");
text("detail-tokens", value.usage?.completion_tokens ?? value.usage?.output_tokens ?? "—");
text("detail-speed", speed(value.timings?.predicted_per_second)); text("detail-format", value.response_format || "text");
$("detail-error").hidden = !value.error; text("detail-error", value.error?.message || ""); content();
}
async function refresh() {
try {
const [status, history] = await Promise.all([api("/v1/status"), api("/api/requests")]);
state = status; records = history.requests;
status.loaded = history.loaded; status.auto_load = history.auto_load;
if (selected && !records.some(r => r.id === selected)) {
selected = detail = null; $("selection").hidden = true; $("no-selection").hidden = false;
}
const inflight = Math.max(status.activity.in_flight, records.filter(active).length);
const loading = records.some(r => r.state === "loading");
text("loaded", loading ? "Loading…" : inflight ? "In use" : status.loaded ? "Loaded" : "Unloaded");
$("model-state").className = `value ${inflight || loading ? "busy" : status.loaded ? "loaded" : ""}`;
text("model", status.model); text("count", records.length);
text("active", inflight ? `${inflight} active / queued` : status.auto_load ? "Loads automatically on the next request" : "No active requests");
const last = records.find(r => !active(r));
text("wall", last ? seconds(last.wallclock_s) : "—"); text("speed", speed(last?.timings?.predicted_per_second));
$("load").disabled = operating || Boolean(inflight) || status.loaded; $("unload").disabled = operating || Boolean(inflight) || !status.loaded;
list(); if (selected) await showDetail();
text("updated", `Live · updated ${new Date().toLocaleTimeString()}`);
if (!operating) notice(actionError);
} catch (error) { notice(error.message); text("updated", "Disconnected · retrying"); $("load").disabled = $("unload").disabled = true; }
}
async function control(load) {
actionError = "";
operating = true; $("load").disabled = $("unload").disabled = true;
text("loaded", load ? "Loading…" : "Unloading…");
try { await api(load ? "/load" : "/unload", {}); notice(""); }
catch (error) { actionError = error.message; notice(actionError); }
finally { operating = false; await refresh(); }
}
$("load").onclick = () => control(true); $("unload").onclick = () => control(false);
$("key").onclick = () => { $("auth").hidden = !$("auth").hidden; if (!$("auth").hidden) $("api-key").focus(); };
$("auth").onsubmit = async event => { event.preventDefault(); apiKey = $("api-key").value.trim(); sessionStorage.setItem("strata.monitor.key", apiKey); $("auth").hidden = true; await refresh(); };
$("theme").onclick = () => { const theme = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = theme; localStorage.setItem("strata.theme", theme); };
$("search").oninput = list; $("filter").onchange = list;
document.querySelectorAll("[data-pane]").forEach(button => { button.onclick = () => { pane = button.dataset.pane; document.querySelectorAll("[data-pane]").forEach(b => b.setAttribute("aria-selected", String(b === button))); content(); }; });
$("copy").onclick = async () => { try { await navigator.clipboard.writeText($("content").textContent); text("copy", "Copied"); setTimeout(() => text("copy", "Copy"), 1500); } catch (_) { notice("Clipboard unavailable; select the text to copy it."); } };
async function poll() { await refresh(); setTimeout(poll, 2000); }
poll();
+1119 -154
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# Strata for Linux: the first run installs everything and starts the model; later runs just start it.
# Needs only an NVIDIA driver (or, experimental: an AMD RX 7900 XT/XTX with the amdgpu driver, see docs/AMD_HIP.md).
# Needs only an NVIDIA driver (or, for an AMD Radeon card, the kernel's amdgpu driver: see docs/AMD_HIP.md).
# Python (with venv) is installed through apt/dnf if it is missing (asks for sudo).
cd "$(dirname "$0")" || exit 1
# Python 3.10+ that can make a venv WITH pip: Debian/Ubuntu ship `venv` without `ensurepip` (that is the separate
+61
View File
@@ -142,6 +142,67 @@ int main() {
check(cache.size() == 1, "oversized put leaves cache unchanged");
check(!cache.make_room(0, one*2+1), "held larger than budget cannot underflow");
}
{
// #342: MAIN -> SUB (9 turns) -> MAIN with 4 slots. Every SUB turn parks the previous turn's live state
// (its reply as generated, which the next request re-rendered) with a chain that holds that turn's
// boundary checkpoint; the copy a turn back adds only that stale tail and is dropped, so MAIN survives.
auto cp = [](std::vector<int32_t> ids) { ConversationCheckpoint c; c.ids = std::move(ids); return c; };
auto with = [](std::vector<int32_t> prefix, std::initializer_list<int32_t> more) {
prefix.insert(prefix.end(), more); return prefix;
};
const std::vector<int32_t> root = {1, 2, 3, 4};
SavedConversation main = image({});
main.live.ids = with(with(root, {10, 11, 12}), {13, 14});
main.checkpoints = {cp(root), cp(with(root, {10, 11, 12}))};
const size_t big = 1 << 20;
ConversationCache cache(big, 4);
check(cache.put(std::move(main)), "park MAIN");
std::vector<int32_t> history = with(root, {20}); // SUB's conversation so far, re-rendered
std::vector<ConversationCheckpoint> chain = {cp(root)};
for (int turn = 1; turn <= 9; ++turn) {
chain.push_back(cp(history)); // the turn boundary the next request resumes from
SavedConversation sub = image({});
sub.live.ids = with(history, {900, (int32_t) turn}); // + the reply with its thinking (stale tail)
sub.checkpoints = chain;
check(cache.put(std::move(sub)), "park SUB turn");
check(cache.size() <= 2, "one parked copy of SUB at a time");
history = with(history, {30, (int32_t) turn}); // the reply as the next request renders it
}
check(cache.evictions() == 0 && cache.superseded() == 8, "8 superseded copies dropped, nothing evicted");
const auto m = cache.best(with(with(root, {10, 11, 12}), {13, 14, 15}), {}, true);
check(m.tokens == 9 && m.live, "MAIN still restores in full");
const auto s = cache.best(with(history, {40}), {}, true);
check(s.tokens == (int64_t) history.size() - 2, "SUB resumes from its last turn boundary");
}
{
// what is NOT superseded: another conversation sharing only the root, an entry without checkpoints, the
// other steering mode, a branch whose deepest checkpoint the new chain does not hold
auto cp = [](std::vector<int32_t> ids) { ConversationCheckpoint c; c.ids = std::move(ids); return c; };
ConversationCache cache(1 << 20, 8);
SavedConversation other = image({1, 2, 3, 4, 50, 51, 52});
other.checkpoints = {cp({1, 2, 3, 4}), cp({1, 2, 3, 4, 50, 51})};
cache.put(std::move(other));
cache.put(image({1, 2, 3, 4, 7, 7})); // no checkpoints
SavedConversation steered = image({1, 2, 3, 4, 60, 61}, false);
steered.checkpoints = {cp({1, 2, 3, 4, 60})};
cache.put(std::move(steered));
SavedConversation branch = image({1, 2, 3, 4, 60, 70, 71});
branch.checkpoints = {cp({1, 2, 3, 4}), cp({1, 2, 3, 4, 60, 70})};
cache.put(std::move(branch));
SavedConversation incoming = image({1, 2, 3, 4, 60, 80, 81});
incoming.checkpoints = {cp({1, 2, 3, 4}), cp({1, 2, 3, 4, 60})};
check(cache.put(std::move(incoming)), "park a conversation sharing roots with all of them");
check(cache.size() == 5 && cache.superseded() == 0, "none of them is superseded");
SavedConversation same_state = image({1, 2, 3, 4, 60, 70}); // live equal to the branch's deepest point
same_state.checkpoints = {cp({1, 2, 3, 4})};
check(cache.put(std::move(same_state)) && cache.superseded() == 1 && cache.size() == 5,
"the live state equal to an entry's deepest checkpoint supersedes it");
SavedConversation huge = image({1, 2, 3, 4, 60, 80});
huge.checkpoints = {cp({1, 2, 3, 4}), cp({1, 2, 3, 4, 60})};
huge.live.gdn.resize(2 << 20);
check(!cache.put(std::move(huge)) && cache.size() == 5 && cache.superseded() == 1,
"an oversized put drops nothing");
}
{
ConversationCache disabled(0,4), no_slots(1024,0);
check(!disabled.enabled() && !no_slots.enabled(), "both disable switches");
+6 -2
View File
@@ -43,8 +43,12 @@ bool layout(const QsaState& st, const ModelGeometry& g, int64_t upto, bool index
// Include the moving spare row, not only completed blocks. The checkpoint
// restore reconstructs that row when rewinding to an earlier prefix.
const int64_t pooled = index && upto > 0 ? upto / s.idx_block + 1 : 0;
// Format 3 identifies snapshot K8V4 only; never pass it to the block movers.
l = {st.kv_hybrid ? 3 : qsa_kv_format(st), cells, pooled, s.page_size, 0, 0, 0, 0, 0};
// Format 3 identifies snapshot K8V4 only; never pass it to the block movers. Rotated INT8 K/V (#293,
// STRATA_KV_ROT=1) is another format too (+16): its bytes mean nothing to a state that does not rotate, so a
// snapshot or prompt checkpoint taken with the rotation never restores into one without it, nor the reverse.
// (Q4_0 is always rotated: nothing to tell apart; without the rotation the format is the one it always was.)
const int rotated = st.kv_rot && !st.kv_q4 && !st.kv_hybrid ? 16 : 0;
l = {(st.kv_hybrid ? 3 : qsa_kv_format(st)) + rotated, cells, pooled, s.page_size, 0, 0, 0, 0, 0};
using conversation_detail::product;
if (!product(l.data, {(uint64_t) cells, (uint64_t) g.n_head_kv, per}) ||
!product(l.scales, {(uint64_t) cells, (uint64_t) g.n_head_kv,
+11
View File
@@ -259,6 +259,17 @@ int main() {
}
auto bad=a; bad.head_dim++;
check(!conversation_kv_restore(bad,f.state,f.g,upto,index,err),"reject incompatible geometry");
if (fmt==kKvInt8) {
// #293: INT8 K/V saved through the Hadamard rotation (STRATA_KV_ROT=1) never restores into a state
// without it, nor the reverse
f.state.kv_rot=true;
ConversationKv rotated;
check(conversation_kv_save(rotated,f.state,f.g,upto,index,err),"save rotated INT8");
check(!conversation_kv_restore(a,f.state,f.g,upto,index,err),"unrotated image into a rotated state rejected");
f.state.kv_rot=false;
check(!conversation_kv_restore(rotated,f.state,f.g,upto,index,err),"rotated image into an unrotated state rejected");
check(conversation_kv_restore(a,f.state,f.g,upto,index,err),"unrotated image still restores");
}
if (!a.k.empty()) {
bad=a; bad.k.pop_back();
check(!conversation_kv_restore(bad,f.state,f.g,upto,index,err),"reject malformed payload");
+41
View File
@@ -73,6 +73,34 @@ const char* compiled_gpu_archs() {
#endif
}
int device_count() {
int count = 0;
if (cudaGetDeviceCount(&count) != cudaSuccess) { // HIP without a usable device reports an error, not 0
cudaGetLastError();
return 0;
}
return count < 0 ? 0 : count;
}
bool device_summary(int ordinal, std::string& name, std::string& detail) {
cudaDeviceProp p{};
if (ordinal < 0 || ordinal >= device_count() || cudaGetDeviceProperties(&p, ordinal) != cudaSuccess) {
cudaGetLastError();
return false;
}
char buf[160];
#if defined(STRATA_USE_HIP)
std::snprintf(buf, sizeof(buf), "arch %s, %.1f GiB, wave%d", base_arch(p.gcnArchName).c_str(),
(double) p.totalGlobalMem / (1024.0 * 1024 * 1024), p.warpSize);
#else
std::snprintf(buf, sizeof(buf), "compute capability %d.%d, %.1f GiB", p.major, p.minor,
(double) p.totalGlobalMem / (1024.0 * 1024 * 1024));
#endif
name = p.name;
detail = buf;
return true;
}
std::string gpu_arch_problem(int ordinal) {
#if defined(STRATA_USE_HIP)
int count = 0;
@@ -92,6 +120,19 @@ std::string gpu_arch_problem(int ordinal) {
#endif
}
std::string device_code_error() {
#if defined(STRATA_USE_HIP)
return ""; // gpu_arch_problem() checks the HIP architectures against STRATA_HIP_ARCHS, before this point
#else
// every .cu of the engine is compiled for the same CMAKE_CUDA_ARCHITECTURES, so this kernel stands for all
cudaFuncAttributes a{};
const cudaError_t e = cudaFuncGetAttributes(&a, poison_kernel);
if (e == cudaSuccess) return {};
cudaGetLastError();
return cudaGetErrorString(e);
#endif
}
DeviceInfo device_info(int ordinal) {
int count = 0;
check(cudaGetDeviceCount(&count), "cudaGetDeviceCount");
+40 -1
View File
@@ -9,6 +9,12 @@
#include <cstdio>
#include <cstring>
#include <string>
#if defined(STRATA_USE_HIP) && defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
static std::string human(uint64_t b) {
char buf[64];
@@ -19,10 +25,14 @@ static std::string human(uint64_t b) {
int main(int argc, char** argv) {
bool selftest = false;
bool list_devices = false;
for (int i = 1; i < argc; ++i) {
if (std::strcmp(argv[i], "--selftest") == 0) selftest = true;
else if (std::strcmp(argv[i], "--list-devices") == 0) list_devices = true;
else if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) {
std::printf("usage: strata-device [--selftest]\n");
std::printf("usage: strata-device [--selftest] [--list-devices]\n"
" --list-devices every GPU the runtime enumerates, numbered as HIP_VISIBLE_DEVICES /\n"
" CUDA_VISIBLE_DEVICES number them, and whether this binary can run it\n");
return 0;
} else {
std::fprintf(stderr, "unknown argument: %s\n", argv[i]);
@@ -30,6 +40,28 @@ int main(int argc, char** argv) {
}
}
// The runtime's numbering, which setup needs on Windows: there an integrated Radeon is HIP device 0 and pushes the
// discrete card to 1, while setup finds the cards in the display-adapter order (#325). No arch check here - the
// cards this binary has no code for are part of the answer. Format (setup.py's hip_devices parses it):
// device N: <name>
// arch gfx1201, 15.9 GiB, wave32 (CUDA: compute capability 12.0, 11.9 GiB)
// cannot run: <why> (only for a card this binary cannot run)
if (list_devices) {
const int count = strata::core::device_count();
if (count == 0) std::printf("(no GPU device)\n");
for (int ordinal = 0; ordinal < count; ++ordinal) {
std::string name, detail;
if (!strata::core::device_summary(ordinal, name, detail)) {
std::printf("device %d: (the runtime cannot describe it)\n", ordinal);
continue;
}
std::printf("device %d: %s\n %s\n", ordinal, name.c_str(), detail.c_str());
if (const std::string why = strata::core::gpu_arch_problem(ordinal); !why.empty())
std::printf(" cannot run: %s\n", why.c_str());
}
return 0;
}
try {
const strata::core::DeviceInfo d = strata::core::device_info(0);
std::printf("device %d: %s\n", d.ordinal, d.name.c_str());
@@ -42,6 +74,13 @@ int main(int argc, char** argv) {
std::printf(" multiprocessors %d\n", d.multi_processor_count);
std::printf(" VRAM total / free %s / %s\n", human(d.total_bytes).c_str(), human(d.free_bytes).c_str());
std::printf(" driver / runtime %d / %d\n", d.driver_version, d.runtime_version);
#if defined(STRATA_USE_HIP) && defined(_WIN32)
// #468 #461: which HIP runtime this process loaded - the one beside the exe, or an AMD driver's System32 copy
if (HMODULE h = GetModuleHandleA("amdhip64_7.dll")) {
char path[MAX_PATH] = {};
if (GetModuleFileNameA(h, path, MAX_PATH) > 0) std::printf(" HIP runtime %s\n", path);
}
#endif
// The planner's view against the card's. A plan that does not fit in what is actually FREE is the
// failure this print exists to make visible at startup rather than at token 4000.
+80
View File
@@ -3,7 +3,9 @@
#include <cuda_runtime.h>
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <utility>
#include <cstring>
@@ -67,6 +69,77 @@ bool read_expert_profile(const std::string& path, int64_t n_layers, int64_t n_ex
return true;
}
std::vector<std::pair<int32_t, int32_t>> rank_learned_profile(int64_t n_layers, int64_t n_expert,
const std::vector<uint8_t>& resident,
const std::vector<double>& heat,
const std::vector<std::pair<int32_t, int32_t>>& prior) {
const size_t n = (size_t) (n_layers * n_expert);
std::vector<int64_t> prior_rank(n, INT64_MAX);
for (size_t r = 0; r < prior.size(); ++r) {
const auto [l, e] = prior[r];
if (l >= 0 && l < n_layers && e >= 0 && e < n_expert) {
int64_t& pr = prior_rank[(size_t) (l * n_expert + e)];
if (pr == INT64_MAX) pr = (int64_t) r;
}
}
std::vector<int64_t> order(n);
for (size_t i = 0; i < n; ++i) order[i] = (int64_t) i;
auto res = [&](int64_t i) { return (size_t) i < resident.size() && resident[(size_t) i] != 0; };
auto ht = [&](int64_t i) { return (size_t) i < heat.size() ? heat[(size_t) i] : 0.0; };
std::stable_sort(order.begin(), order.end(), [&](int64_t a, int64_t b) {
if (res(a) != res(b)) return res(a);
if (ht(a) != ht(b)) return ht(a) > ht(b);
if (prior_rank[(size_t) a] != prior_rank[(size_t) b]) return prior_rank[(size_t) a] < prior_rank[(size_t) b];
return a < b;
});
std::vector<std::pair<int32_t, int32_t>> ranked(n);
for (size_t r = 0; r < n; ++r)
ranked[r] = {(int32_t) (order[r] / n_expert), (int32_t) (order[r] % n_expert)};
return ranked;
}
bool write_expert_profile(const std::string& path, int64_t n_layers, int64_t n_expert,
const std::vector<std::pair<int32_t, int32_t>>& ranked, std::string& err) {
if (n_layers <= 0 || n_expert <= 0 || n_layers > 65535 || n_expert > 65535) {
err = "write_expert_profile: the model's layout does not fit the format";
return false;
}
std::vector<int32_t> table((size_t) (n_layers * n_expert), -1);
std::vector<uint16_t> pairs;
pairs.reserve(ranked.size() * 2);
for (size_t r = 0; r < ranked.size(); ++r) {
const auto [l, e] = ranked[r];
if (l < 0 || l >= n_layers || e < 0 || e >= n_expert) {
err = "write_expert_profile: a ranked pair is out of range";
return false;
}
table[(size_t) (l * n_expert + e)] = (int32_t) r;
pairs.push_back((uint16_t) l);
pairs.push_back((uint16_t) e);
}
const uint32_t hdr[5] = {1u, (uint32_t) n_layers, (uint32_t) n_expert, (uint32_t) ranked.size(),
(uint32_t) ranked.size()};
const std::string tmp = path + ".tmp";
std::FILE* f = std::fopen(tmp.c_str(), "wb");
if (f == nullptr) {
err = "write_expert_profile: cannot create " + tmp;
return false;
}
// the format is little-endian (make_profile.py's "<"): so is every machine this engine runs on
bool ok = std::fwrite("STRP", 1, 4, f) == 4 && std::fwrite(hdr, 4, 5, f) == 5 &&
(pairs.empty() || std::fwrite(pairs.data(), 2, pairs.size(), f) == pairs.size()) &&
std::fwrite(table.data(), 4, table.size(), f) == table.size();
ok = (std::fclose(f) == 0) && ok;
std::error_code ec;
if (ok) std::filesystem::rename(tmp, path, ec); // replaces an existing file (MoveFileEx / rename(2))
if (!ok || ec) {
std::filesystem::remove(tmp, ec);
err = "write_expert_profile: cannot write " + path;
return false;
}
return true;
}
ExpertCache::~ExpertCache() { close(); }
#if defined(STRATA_USE_HIP)
@@ -182,7 +255,14 @@ bool ExpertCache::open_sized(const std::vector<int64_t>& slot_bytes, int64_t n_l
return false;
}
#endif
// #369: each layer's cursor at the bottom of its own range, as open() seeds it - open() above ran on byte-sized
// "slots", so its seeds are not slot indices
layer_next_.assign((size_t) (n_layers > 0 ? n_layers : 0), 0);
for (int64_t l = 0; l < n_layers; ++l) {
int64_t lo = 0, hi = 0;
layer_slot_range(l, lo, hi);
layer_next_[(size_t) l] = (int32_t) lo;
}
return true;
}
+42 -7
View File
@@ -1024,6 +1024,23 @@ bool FileExpertSource::pin_cache_complement(
uint64_t bytes = 0;
if (!detail::make_cache_complement_plan(n_layers_, n_expert_, layer_blob_bytes_, primary_gpu_pairs,
additional_gpu_pairs, offsets, bytes, err)) return false;
#if defined(_WIN32)
// #467: the GPU cache's pre-fill touched its experts through the mapping (~19 GiB on a 24 GB card), and Windows
// counts those file pages in this process's working set, not as available: a 32 GB PC read 0.44 GiB here
// (20.7 GiB before the start). Trimmed, they move to the standby list (still cached, counted as available).
// Resident mode only: nothing else calls this function. Locked/pinned pages stay; the rest fault back softly.
{
uint64_t before = 0, after = 0;
const bool read_before = available_memory_bytes(before);
(void) SetProcessWorkingSetSize(GetCurrentProcess(), (SIZE_T) -1, (SIZE_T) -1);
if (read_before && available_memory_bytes(after))
std::fprintf(stderr, "FileExpertSource: available RAM %.2f GiB, %.2f GiB after the mapped experts left the "
"process working set (#467)\n",
(double) before / 1073741824.0, (double) after / 1073741824.0);
}
#endif
const bool what_fits = budget_bytes == kResidentWhatFits; // #467: the soft mode's second try
uint64_t budget_physical = 0; // #403: the RAM reading a budget was sized from (0: no budget)
if (budget_bytes > 0) {
// CS-T: a RAM budget. The complement's experts in `rank` order (the expert profile, hottest first) while
// they fit, the rest left on the mapped files; clamped to what the RAM has room for.
@@ -1032,13 +1049,25 @@ bool FileExpertSource::pin_cache_complement(
err = "FileExpertSource: cannot determine available RAM for --resident-budget-gib";
return false;
}
budget_physical = physical;
const uint64_t room = physical > headroom_bytes ? physical - headroom_bytes : 0;
if (budget_bytes > room) {
std::fprintf(stderr, "FileExpertSource: --resident-budget-gib %.2f is more than the RAM has room for "
"(%.2f GiB available minus %.0f GiB headroom): %.2f GiB\n",
(double) budget_bytes / 1073741824.0, (double) physical / 1073741824.0,
(double) headroom_bytes / 1073741824.0, (double) room / 1073741824.0);
budget_bytes = room;
// #403: 256 MiB under the room, so the engine's own allocations after this reading still leave the
// headroom (a budget clamped to exactly the room failed the safety check below on a reading a few MB
// lower). An unclamped budget is unchanged.
const uint64_t margin = 256ull << 20;
const uint64_t clamped = room > margin ? room - margin : 0;
if (what_fits)
std::fprintf(stderr, "FileExpertSource: RAM room for the complement: %.2f GiB (%.2f GiB available "
"minus %.0f GiB headroom and a 0.25 GiB margin)\n",
(double) clamped / 1073741824.0, (double) physical / 1073741824.0,
(double) headroom_bytes / 1073741824.0);
else
std::fprintf(stderr, "FileExpertSource: --resident-budget-gib %.2f is more than the RAM has room for "
"(%.2f GiB available minus %.0f GiB headroom and a 0.25 GiB margin): %.2f GiB\n",
(double) budget_bytes / 1073741824.0, (double) physical / 1073741824.0,
(double) headroom_bytes / 1073741824.0, (double) clamped / 1073741824.0);
budget_bytes = clamped;
}
std::vector<uint64_t> ranked(offsets.size(), kNoComplement);
uint64_t at = 0;
@@ -1054,6 +1083,10 @@ bool FileExpertSource::pin_cache_complement(
at += b;
++held;
}
if (what_fits && held == 0) { // #467: nothing to keep - the caller's plain mmap fallback, not an empty copy
err = "FileExpertSource: the RAM has no room for any expert of the complement";
return false;
}
std::fprintf(stderr, "FileExpertSource: RAM budget %.2f GiB: %lld of the %.2f GiB of experts the GPU cache does "
"not hold, by profile rank; the rest are read from the files\n",
(double) budget_bytes / 1073741824.0, (long long) held, (double) bytes / 1073741824.0);
@@ -1065,8 +1098,10 @@ bool FileExpertSource::pin_cache_complement(
const bool lend = lend_from_slot >= 0 && lend_from_slot < n_slots && additional_gpu_pairs.empty();
uint64_t budget = std::numeric_limits<uint64_t>::max();
if (bytes > 0 || lend) {
uint64_t physical = 0;
if (!available_memory_bytes(physical)) {
// #403: with a budget, the reading it was sized from - a second reading a few MB lower (the engine's own
// allocations, the file cache) failed a budget the first one had clamped. (A budget turns `lend` off.)
uint64_t physical = budget_physical;
if (physical == 0 && !available_memory_bytes(physical)) {
err = "FileExpertSource: cannot determine available RAM for the resident-memory safety check";
return false;
}
+15 -6
View File
@@ -22,6 +22,7 @@
#include "strata/kernels/native_router.hpp"
#include "strata/kernels/native_qsa.hpp"
#include "strata/kernels/native_qsa_indexer.hpp"
#include "strata/kernels/mrope.hpp"
#include "strata/kernels/native_rope.hpp"
#include "strata/kernels/native_flash_attn.hpp"
#include "strata/kernels/fused_gr.hpp"
@@ -172,8 +173,9 @@ 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, g_kv_q4 = false, g_kv_hybrid = false; }
namespace { bool g_kv_int8 = false, g_kv_q4 = false, g_kv_hybrid = false, g_kv_int8_rot = false; }
void qsa_set_kv_int8(bool enabled) { g_kv_int8 = enabled; }
void qsa_set_kv_int8_rotate(bool enabled) { g_kv_int8_rot = 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; }
@@ -563,6 +565,7 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
st.kv_int8 = false;
st.kv_q4 = false;
}
st.kv_rot = st.kv_q4 || (st.kv_int8 && g_kv_int8_rot); // K8V4 rotates only V (below)
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
@@ -662,6 +665,9 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
hc.data(), hs.data());
cudaMemcpy(st.cos_tab, hc.data(), hc.size() * 4, cudaMemcpyHostToDevice);
cudaMemcpy(st.sin_tab, hs.data(), hs.size() * 4, cudaMemcpyHostToDevice);
st.owns_rope = true;
if (s.n_rot == 64) // the native and prompt-path rope kernels read it (mrope.hpp, STRATA_ROPE_TABLE=1)
strata::kernels::rope_table_set(st.cos_tab, st.sin_tab, (int) max_cells, strata::kernels::rope_scaling());
}
// the page table starts as the IDENTITY, which is the simplest legal mapping and what a caller that does
// not page at all wants; a streamed state starts with nothing resident, a ring at `block % n_slots`.
@@ -899,11 +905,14 @@ if (st.kv_hybrid) {
strata::kernels::fwht256_inplace_cuda(b.vcur, g.n_head_kv, stream);
kv_append_q8_step(st.k_q, st.k_q, st.k_scale, st.k_scale, st.page_table, st.step, b.kcur, b.kcur, s, stream, nullptr); // mode 0: no host mirror
strata::kernels::kv_append_q4_step(st.v_q4, st.v_q4, st.page_table, st.step, b.vcur, b.vcur, s, stream, nullptr);
} else 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)
} else {
if (st.kv_rot) { // rotated K and V (kv_q4.hpp): Q4_0, and INT8 with STRATA_KV_ROT=1
strata::kernels::fwht256_inplace_cuda(b.kcur, g.n_head_kv, stream);
strata::kernels::fwht256_inplace_cuda(b.vcur, g.n_head_kv, stream);
}
if (st.kv_q4) {
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 && !st.kv_hybrid) dump_slot(dump, g, layer, (const float*) st.k_pool, base, nvk / 2, stream); if (!st.kv_int8 && !st.kv_hybrid) 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()) {
} 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); } /* not K8V4 */ { 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 && !st.kv_hybrid) dump_slot(dump, g, layer, (const float*) st.k_pool, base, nvk / 2, stream); if (!st.kv_int8 && !st.kv_hybrid) 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, rope_scaling(), stream);
@@ -937,7 +946,7 @@ 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 (st.kv_rot) 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) {
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);
@@ -964,7 +973,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 || st.kv_hybrid) strata::kernels::fwht256_inplace_cuda(b.attn, g.n_head, stream); // the output back: H is self-inverse
if (st.kv_rot || st.kv_hybrid) 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.
+47 -4
View File
@@ -372,6 +372,45 @@ void MtpDrafter::set_draft_history(const int32_t* tail, int64_t n_tail, int32_t
coupled_hist_base(tail, n_tail, next, h, h_chist_ + (kCoupledHistCap - h));
}
namespace {
// #474: what to change when the draft head's token subset does not fit the VRAM left - a smaller subset that setup
// ships (data/draft_vocab_*.bin; their token counts below), and how to pick it. The start used to stop at "the draft
// head does not fit" with no hint. Text only, after the failure: nothing changes for a start that fits.
void draft_head_hint(int64_t n_tokens, int64_t row_bytes) {
size_t free_b = 0, total_b = 0;
const bool have_free = cudaMemGetInfo(&free_b, &total_b) == cudaSuccess;
auto mib = [&](int64_t n) { return (double) (n * row_bytes) / 1048576.0; };
std::string free_s;
if (have_free) {
char b[64];
std::snprintf(b, sizeof(b), " and %.0f MiB is free", (double) free_b / 1048576.0);
free_s = b;
}
std::fprintf(stderr, "strata mtp: the draft head over %lld tokens needs %.0f MiB of VRAM%s.\n",
(long long) n_tokens, mib(n_tokens), free_s.c_str());
struct Subset { const char* name; int64_t tokens; const char* what; };
static const Subset smaller[] = {{"cyrillic", 58963, "English, code and the Cyrillic script"},
{"en", 40525, "English and code"}};
std::string opts;
for (const Subset& s : smaller)
if (s.tokens < n_tokens) {
char b[160];
std::snprintf(b, sizeof(b), "%s--draft-vocab %s (%s, ~%.0f MiB)", opts.empty() ? "" : " or ", s.name,
s.what, mib(s.tokens));
opts += b;
}
if (!opts.empty())
std::fprintf(stderr, "strata mtp: hint: a smaller draft vocabulary needs less VRAM: %s. Start once with it - "
"START-HERE.bat --draft-vocab en (Windows) or ./setup.sh --draft-vocab en - and the model "
"keeps it (\"draft_vocab\" in its strata-*.json config); or a smaller --context in "
"setup.\n",
opts.c_str());
else
std::fprintf(stderr, "strata mtp: hint: this is already the smallest shipped draft vocabulary: a smaller "
"--context (or closing what else uses the GPU) leaves it room.\n");
}
} // namespace
bool MtpDrafter::bind(const WeightTable& wt, const NativeHead* head, const float* window_R, std::string& err) {
const OnDevice on_device(device_);
wt_ = &wt;
@@ -395,6 +434,7 @@ bool MtpDrafter::bind(const WeightTable& wt, const NativeHead* head, const float
if (cudaMalloc((void**) &dvocab_, raw.size()) != cudaSuccess ||
cudaMalloc((void**) &dhead_, (size_t) (n_dvocab_ * row_bytes)) != cudaSuccess) {
err = "mtp: the draft head does not fit";
draft_head_hint(n_dvocab_, row_bytes);
return false;
}
cudaMemcpy(dvocab_, raw.data(), raw.size(), cudaMemcpyHostToDevice);
@@ -471,12 +511,15 @@ 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_q4) { // Q4_0 KV (kv_q4.hpp): rotated K and V
if (st_.kv_rot) { // rotated K and V (kv_q4.hpp): Q4_0, and INT8 with STRATA_KV_ROT=1
fwht256_inplace_cuda(kcur_ + t * NKV * HD, NKV, cs);
fwht256_inplace_cuda(vcur_ + t * NKV * HD, NKV, cs);
}
// stored in the state's own format (#293 appended rotated INT8 K/V as Q4_0, into pools INT8 never has)
if (st_.kv_q4)
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)
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
@@ -493,12 +536,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);
if (st_.kv_rot) 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);
if (st_.kv_rot) 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);
+12
View File
@@ -53,6 +53,16 @@ bool NativeDense::served_names(const std::vector<std::string>& shards, bool incl
}
}
bool NativeDense::keep_unquantized_ple_key(const std::string& pack_dir, std::set<std::string>& skip,
std::string& err) {
const std::string key = "blk.1.ple_key.weight";
if (!skip.count(key)) return true;
int code_bits = -1;
if (!WeightTable::index_code_bits(pack_dir, key, code_bits, err)) return false;
if (code_bits == 0) skip.erase(key);
return true;
}
NativeDense::~NativeDense() {
if (scratch_) cudaFree(scratch_);
for (void* p : weights_) cudaFree(p);
@@ -139,6 +149,8 @@ bool NativeDense::load(const std::vector<std::string>& shards, WeightTable& tabl
auto& ref = found->second;
if (ref.native_data) { err = "native dense: override already attached"; return false; }
if (!strata::kernels::native_mmvq_supported(tensor.type)) continue;
// #326: the pack keeps an unquantized (--compat-bf16) key, which the PLE reads from the arena
if (tensor.name == "blk.1.ple_key.weight" && !ref.quantized()) continue;
if (!ref.quantized() || tensor.shape.size() != 2 ||
ref.ne0 <= 0 || ref.ne0 > INT_MAX || ref.ne1 <= 0 || ref.ne1 > INT_MAX ||
tensor.shape[0] != (uint64_t) ref.ne0 || tensor.shape[1] != (uint64_t) ref.ne1) {
+25 -2
View File
@@ -108,13 +108,15 @@ NativeEmbed::~NativeEmbed() {
bool NativeEmbed::load(const std::vector<std::string>& shards, int64_t n_embd, int64_t n_vocab, std::string& err) {
try {
const strata::GgufModel model(shards);
err = strata::check_architecture(model.meta());
// --embd-gguf's one-tensor file (tools/embd_bf16_pack.py) says "strata-embd": only its tensor is checked
const strata::MetaValue* arch = model.meta().get("general.architecture");
err = arch != nullptr && arch->s == "strata-embd" ? std::string() : strata::check_architecture(model.meta());
if (!err.empty()) { err = "native embedding: " + err; return false; }
size_t at = 0;
const strata::TensorInfo* t = model.find("token_embd.weight", &at);
const strata::GgufFile& gguf = model.shard(at);
if (!t || t->shape.size() != 2 || t->shape[0] != (uint64_t) n_embd || t->shape[1] != (uint64_t) n_vocab ||
!strata::kernels::iq_supported((int) t->type) || n_embd % 256) {
!strata::kernels::embed_type_supported((int) t->type) || n_embd % 256) {
err = "native embedding: token_embd.weight is absent, of another shape, or of a type without a GPU "
"dequantizer";
return false;
@@ -148,10 +150,31 @@ bool NativeEmbed::load(const std::vector<std::string>& shards, int64_t n_embd, i
} else {
std::memcpy(host_, gguf.tensor_data(*t), bytes_);
void* d = nullptr;
#if defined(STRATA_USE_HIP)
// #325: the Windows HIP stack can refuse the device alias of a mapped allocation (and, when it gives
// one, it is the host address itself - unified addressing; kernels read it correctly there, a
// device-to-device copy into it does not land: tests/hip/mapped_alias). The table is only gathered from,
// so without an alias it goes into VRAM like the unpinnable case above, instead of failing the start.
if (cudaHostGetDevicePointer(&d, host_, 0) != cudaSuccess || d == nullptr) {
cudaGetLastError();
d = nullptr;
if (cudaMalloc(&d, bytes_) != cudaSuccess ||
cudaMemcpy(d, gguf.tensor_data(*t), bytes_, cudaMemcpyHostToDevice) != cudaSuccess) {
if (d) cudaFree(d);
cudaGetLastError();
err = "native embedding: no device alias for the mapped table, and no VRAM to copy it into";
return false;
}
cudaFreeHost(host_); // the destructor frees dev_ when host_ is null
host_ = nullptr;
std::fprintf(stderr, "strata: native embedding: no device alias for the mapped table, kept in VRAM\n");
}
#else
if (cudaHostGetDevicePointer(&d, host_, 0) != cudaSuccess) {
err = "native embedding: no device alias for the mapped table";
return false;
}
#endif
dev_ = d;
}
type_ = (int) t->type;
+9
View File
@@ -1,5 +1,6 @@
// src/core/session.cpp - one token through all 48 layers. See the header for why the graphs are per-layer.
#include "strata/core/session.hpp"
#include "strata/kernels/mrope.hpp"
#include "strata/core/progress.hpp"
#include "strata/kernels/qsa.hpp"
@@ -127,6 +128,14 @@ uint64_t session_init(const ModelGeometry& g, int64_t max_cells, int64_t k, void
return used;
}
void session_release(SessionState& s) {
for (int64_t j = 0; s.qsa_states != nullptr && j < s.qsa_alloc; ++j)
if (s.qsa_states[j].owns_rope) {
strata::kernels::rope_table_release(s.qsa_states[j].cos_tab);
s.qsa_states[j].owns_rope = false;
}
}
void session_zero(SessionState& s, const ModelGeometry& g, const float* R_init, void* stream) {
cudaStream_t cs = (cudaStream_t) stream;
// the residual: `hc` copies of the one vector a caller hands in. A real sequence's first token is the
+74 -13
View File
@@ -177,6 +177,7 @@ Verifier::~Verifier() {
if (commit_exec_) cudaGraphExecDestroy(commit_exec_);
if (cs_) cudaStreamDestroy(cs_);
if (copy_) { cudaStreamSynchronize(copy_); cudaStreamDestroy(copy_); }
if (commit_done_) cudaEventDestroy(commit_done_);
if (arena_) cudaFree(arena_);
void* hosts[] = {h_tok_, h_step_, h_pos_, h_commit_, h_ple_, h_out_, h_x_, h_ids_, h_w_, h_seq_, h_flag_, h_ymiss_,
h_flagA_, h_plan_, h_flagB_};
@@ -194,6 +195,7 @@ bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState&
}
release_gpu_fn().store(&release_live_verifiers);
cudaGetDevice(&device_); // a layer split's stage on another GPU: its streams, graphs and buffers live there
strata::kernels::fused_gr_check(); // once per card: which bitwise-equal hyper-connection read runs there
wt_ = &wt;
g_ = &g;
ss_ = &ss;
@@ -346,6 +348,10 @@ bool Verifier::init(const WeightTable& wt, const ModelGeometry& g, SessionState&
err = "verify: stream create failed";
return false;
}
if (cudaEventCreateWithFlags(&commit_done_, cudaEventDisableTiming) != cudaSuccess) {
err = "verify: event create failed";
return false;
}
// E-6: a layer whose routed experts are all resident is planned on the device (STRATA_VERIFY_DEVICE_PLAN=1: on;
// exact, but neutral on RIBPC 1-2 GPUs: off by default)
{
@@ -567,7 +573,7 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
native_mmvq(wv->native_type, wv->native_data, xq_, vcur_ + tb * NKV * HD, (int) N, (int) (NKV * HD), n, cs);
if (qb) norm_rope(kcur_ + tb * NKV * HD, wkn, (int) (n * NKV), (int) HD, pos_k + tb * NKV);
else 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
if (st.kv_rot) { // K and V rotated before they are stored (kv_q4.hpp)
fwht256_inplace_cuda(kcur_ + tb * NKV * HD, (int64_t) n * NKV, cs);
fwht256_inplace_cuda(vcur_ + tb * NKV * HD, (int64_t) n * NKV, cs);
} else if (st.kv_hybrid) { // K8V4: only V is rotated
@@ -607,6 +613,7 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
return false;
}
norm_rope(qcur_ + tb * NH * HD, wqn, (int) (n * NH), (int) HD, pos_ + tb * NH);
if (st.kv_rot) fwht256_inplace_cuda(qcur_ + tb * NH * HD, (int64_t) n * NH, cs); // <Hq, Hk> = <q, k>
bf16_gemv_fp32_mmvf_multi(mixed_ + tb * N, N, (const uint16_t*) wiq->data, qidx_ + tb * IQ * ID, IQ * ID,
N, IQ * ID, n, cs);
norm_rope(qidx_ + tb * IQ * ID, wiqn, (int) (n * IQ), (int) ID, pos_i + tb * IQ);
@@ -619,7 +626,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>
if (st.kv_rot) fwht256_inplace_cuda(qc, NH, cs); // <Hq, Hk> = <q, k>
}
for (int t = tb; t < te; ++t) {
float* qx = qidx_ + t * IQ * ID;
@@ -640,7 +647,7 @@ bool Verifier::record_window(int T, cudaStream_t cs, std::string& err) {
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);
stamp(l, 13, grp);
if (st.kv_q4 || st.kv_hybrid) fwht256_inplace_cuda(attn_ + tb * NH * HD, (int64_t) n * NH, cs); // back: H^-1 = H
if (st.kv_rot || st.kv_hybrid) fwht256_inplace_cuda(attn_ + tb * NH * HD, (int64_t) n * NH, cs); // back: H^-1 = H
if (qb) native_qsa_gate_apply(attn_ + tb * NH * HD, qfull_ + tb * NH * 2 * HD, attn32_ + tb * NH * HD,
(int) (n * NH), (int) HD, cs);
else
@@ -857,7 +864,7 @@ std::string Verifier::profile_report() {
"k/v+norm-rope", "kv+idx append", "q+q-idx", "scores+topk", "kv-resolve",
"attention", "gate", "", "out-proj", "hc-read1+router", "shared+quant",
"waitA", "VRAM hits", "waitB", "PCIe grp", "waitCPU", "copy+combine",
"(gap)", "head", " hc0 norm", " hc0 down", " hc0 up", "", ""};
"(gap)", "head", " hc0 norm", " hc0 down", " hc0 up", "", "", ""};
std::string out;
char b[80];
double total = 0;
@@ -1126,6 +1133,13 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
cudaMemcpy(prof_h_.data(), prof_, prof_h_.size() * 8, cudaMemcpyDeviceToHost);
const int64_t L = g.n_layers;
auto at = [&](int64_t l, int i) { return prof_h_[(size_t) (l * kProfPer + i)]; };
// D8: the derived columns below read stamps the hc-read kernels write themselves or the next
// layer's first. A slot no kernel stamped is 0 and its unsigned difference wrapped to ~1e19 ns -
// which is why (gap)/head/hc0 printed ~1e14 ms per window. A missing or out-of-order stamp now
// contributes nothing.
const auto gap = [](unsigned long long to, unsigned long long from) {
return (from != 0 && to != 0 && to >= from) ? (double) (to - from) : 0.0;
};
for (int64_t l = 0; l < L; ++l) {
const int kind = is_qsa_layer(g, l) ? 1 : 0;
unsigned long long prev = at(l, 0);
@@ -1135,13 +1149,16 @@ bool Verifier::run(int T, const int32_t* tokens, int64_t pos0, PoolMultiFn pool,
prof_sum_[kind][i] += (double) (x - prev);
prev = x;
}
if (l + 1 < L) prof_sum_[kind][25] += (double) (at(l + 1, 0) - at(l, 24));
prof_sum_[kind][27] += (double) (at(l, 27) - at(l, 0)); // hc-read0: norm
prof_sum_[kind][28] += (double) (at(l, 28) - at(l, 27)); // down
prof_sum_[kind][29] += (double) (at(l, 1) - at(l, 28)); // up
prof_sum_[kind][1] -= (double) (at(l, 1) - at(l, 0)); // (hc-read0 shown split)
if (l + 1 < L) prof_sum_[kind][25] += gap(at(l + 1, 0), at(l, 24));
const double dn = gap(at(l, 27), at(l, 0)), dd = gap(at(l, 28), at(l, 27)), du = gap(at(l, 1), at(l, 28));
if (dn > 0 && dd > 0 && du > 0) { // the split exists: show it split, not twice
prof_sum_[kind][27] += dn; // hc-read0: norm
prof_sum_[kind][28] += dd; // down
prof_sum_[kind][29] += du; // up (through both halves, as before)
prof_sum_[kind][1] -= gap(at(l, 1), at(l, 0)); // (hc-read0 shown split)
}
}
prof_sum_[0][26] += (double) (at(L, 1) - at(L, 0));
prof_sum_[0][26] += gap(at(L, 1), at(L, 0));
++prof_windows_;
}
// ---- a sampled or penalized request: the head's sampling again, host-side so its parameters are this call's
@@ -1274,14 +1291,32 @@ bool Verifier::window_logprobs(const int32_t* targets, int T, int64_t pos0, int3
const double extra = has_extra ? (double) row[extra_id] - lse : NAN;
const double without = has_extra && tgt != extra_id && sum_without > 0.0
? (double) row[tgt] - (maxv + std::log(sum_without)) : NAN;
std::fprintf(out, "%lld\t%d\t%.9f\t%lld\t%.9f\t%d\t%.9f\t%.9f\n", (long long) (pos0 + t), (int) tgt,
std::fprintf(out, "%lld\t%d\t%.9f\t%lld\t%.9f\t%d\t%.9f\t%.9f", (long long) (pos0 + t), (int) tgt,
(double) row[tgt] - lse, (long long) top, maxv - lse, (int) (top == (int64_t) tgt), extra,
without);
// STRATA_LOGPOS_TOPK=K: the K most likely tokens and their log-probabilities too (`id:logprob`), for a
// top-k comparison with another engine on the same tokens (docs/UNSLOTH_Q4.md)
static const int topk = [] {
const char* v = std::getenv("STRATA_LOGPOS_TOPK");
return v != nullptr ? std::max(0, std::min(256, std::atoi(v))) : 0;
}();
if (topk > 0) {
std::vector<int32_t> order((size_t) n_vocab_);
for (int64_t v = 0; v < n_vocab_; ++v) order[(size_t) v] = (int32_t) v;
std::partial_sort(order.begin(), order.begin() + topk, order.end(),
[&](int32_t a, int32_t b) { return row[a] > row[b]; });
for (int j = 0; j < topk; ++j)
std::fprintf(out, "\t%d:%.6f", order[(size_t) j], (double) row[order[(size_t) j]] - lse);
}
std::fprintf(out, "\n");
}
std::fflush(out);
return true;
}
namespace { bool g_commit_async = false; }
void Verifier::set_commit_async(bool on) { g_commit_async = on && std::getenv("STRATA_COMMIT_SYNC") == nullptr; }
bool Verifier::commit(int n_keep, std::string& err) {
const OnDevice on_device(device_);
if (n_keep < 1 || n_keep > last_t_) { err = "verify: commit count out of range"; return false; }
@@ -1292,8 +1327,17 @@ bool Verifier::commit(int n_keep, std::string& err) {
std::atomic_thread_fence(std::memory_order_seq_cst);
const cudaError_t le = cudaGraphLaunch(commit_exec_, cs_);
if (le != cudaSuccess) { err = std::string("verify: commit launch: ") + cudaGetErrorString(le); return false; }
const cudaError_t se = cudaStreamSynchronize(cs_);
if (se != cudaSuccess) { err = std::string("verify: commit: ") + cudaGetErrorString(se); return false; }
// set_commit_async: no wait here - the next window runs on the same stream after it, and the drafter (its own
// stream) reads only this window's final rows and its own K/V. h_commit_ is next written after the next window's
// results are read, i.e. after this graph has run. Everything else waits on commit_done_ (wait_commit).
if (!g_commit_async || next_ != nullptr) {
const cudaError_t se = cudaStreamSynchronize(cs_);
if (se != cudaSuccess) { err = std::string("verify: commit: ") + cudaGetErrorString(se); return false; }
} else {
const cudaError_t re = cudaEventRecord(commit_done_, cs_);
if (re != cudaSuccess) { err = std::string("verify: commit event: ") + cudaGetErrorString(re); return false; }
commit_pending_ = true;
}
if (ple_stage()) // stages that share one session must advance it once
for (int t = 0; t < n_keep; ++t) {
ss_->ple_prev[0] = ss_->ple_prev[1];
@@ -1303,4 +1347,21 @@ bool Verifier::commit(int n_keep, std::string& err) {
return next_ == nullptr || next_->commit(n_keep, err);
}
bool Verifier::wait_commit(std::string& err) {
if (commit_pending_) {
const OnDevice on_device(device_);
commit_pending_ = false;
const cudaError_t se = cudaEventSynchronize(commit_done_);
if (se != cudaSuccess) { err = std::string("verify: commit: ") + cudaGetErrorString(se); return false; }
}
return next_ == nullptr || next_->wait_commit(err);
}
bool Verifier::copy_logits(int t, float* host) const {
if (next_ != nullptr) return next_->copy_logits(t, host); // a layer split: the head is on the last stage
if (head_logits_ == nullptr || host == nullptr || t < 0 || n_vocab_ <= 0) return false;
return cudaMemcpy(host, head_logits_ + (size_t) t * (size_t) n_vocab_, (size_t) n_vocab_ * sizeof(float),
cudaMemcpyDeviceToHost) == cudaSuccess;
}
} // namespace strata::core
+23
View File
@@ -118,6 +118,29 @@ bool WeightTable::pool_bytes(const std::string& pack_dir, uint64_t& out, std::st
return true;
}
bool WeightTable::index_code_bits(const std::string& pack_dir, const std::string& name, int& code_bits,
std::string& err) {
const std::string path = pack_dir + "/index.txt";
std::FILE* f = std::fopen(path.c_str(), "rb");
if (!f) { err = "cannot open " + path; return false; }
char line[1024];
code_bits = -1;
while (std::fgets(line, sizeof line, f)) {
if (line[0] == '#') continue;
char row[256] = {0};
unsigned long long dummy = 0;
long long ne = 0;
int i1 = 0, i2 = 0, bits = 0;
// the first ten fields of the row `load` parses: name file kind src_off src_bytes dst_off dst_bytes ne0 ne1 code_bits
if (std::sscanf(line, "%255s %d %d %llu %llu %llu %llu %lld %lld %d", row, &i1, &i2, &dummy, &dummy, &dummy,
&dummy, &ne, &ne, &bits) != 10)
continue;
if (name == row) { code_bits = bits; break; }
}
std::fclose(f);
return true;
}
bool WeightTable::load(const std::string& pack_dir, void* arena_base, uint64_t arena_bytes, std::string& err,
const std::set<std::string>* skip) {
const std::string path = pack_dir + "/index.txt";
+205 -30
View File
@@ -27,8 +27,8 @@ constexpr uint64_t pack_head(uint32_t epoch, uint32_t n, uint32_t i) {
}
} // namespace
std::vector<int> physical_cores(bool skip_first) {
std::vector<int> cores;
CpuTopology detect_cpu_topology(bool skip_first, PoolAffinity affinity) {
CpuTopology topo;
#if defined(_WIN32)
// Ask the OS rather than assuming a layout. `hardware_concurrency()` returns LOGICAL processors, and on
// every SMT machine half of them are siblings - pinning one worker to each of the first N would put two
@@ -36,30 +36,111 @@ std::vector<int> physical_cores(bool skip_first) {
DWORD len = 0;
GetLogicalProcessorInformationEx(RelationProcessorCore, nullptr, &len);
if (len == 0) {
for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) cores.push_back((int) i);
} else {
std::vector<char> buf(len);
if (GetLogicalProcessorInformationEx(RelationProcessorCore,
(PSYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX) buf.data(), &len)) {
const char* p = buf.data();
const char* end = p + len;
while (p < end) {
const auto* e = (const SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX*) p;
if (e->Relationship == RelationProcessorCore) {
const GROUP_AFFINITY& g = e->Processor.GroupMask[0];
for (int bit = 0; bit < 64; ++bit)
if (g.Mask & (1ull << bit)) { cores.push_back((int) (g.Group * 64 + bit)); break; }
for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) topo.worker_cores.push_back((int) i);
if (skip_first && !topo.worker_cores.empty()) {
topo.host_core = topo.worker_cores.front();
topo.worker_cores.erase(topo.worker_cores.begin());
}
return topo;
}
std::vector<char> buf(len);
if (GetLogicalProcessorInformationEx(RelationProcessorCore,
(PSYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX) buf.data(), &len)) {
const char* p = buf.data();
const char* end = p + len;
struct CoreDesc {
uint8_t efficiency = 0;
bool has_smt = false;
std::vector<int> lps;
};
std::vector<CoreDesc> descs;
while (p < end) {
const auto* e = (const SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX*) p;
if (e->Relationship == RelationProcessorCore) {
CoreDesc cd;
cd.efficiency = e->Processor.EfficiencyClass;
cd.has_smt = (e->Processor.Flags & LTP_PC_SMT) != 0;
const GROUP_AFFINITY& g = e->Processor.GroupMask[0];
for (int bit = 0; bit < 64; ++bit) {
if (g.Mask & (1ull << bit)) {
cd.lps.push_back((int) (g.Group * 64 + bit));
}
}
p += e->Size;
if (!cd.lps.empty()) {
descs.push_back(std::move(cd));
}
}
p += e->Size;
}
uint8_t min_eff = 255, max_eff = 0;
for (const auto& c : descs) {
min_eff = (std::min)(min_eff, c.efficiency);
max_eff = (std::max)(max_eff, c.efficiency);
}
topo.is_hybrid = (max_eff > min_eff);
if (topo.is_hybrid) {
for (const auto& c : descs) {
if (c.efficiency == max_eff) {
topo.p_cores++;
topo.p_threads += (int) c.lps.size();
} else {
topo.e_cores++;
}
}
} else {
topo.p_cores = (int) descs.size();
for (const auto& c : descs) topo.p_threads += (int) c.lps.size();
}
if (affinity == PoolAffinity::All || !topo.is_hybrid) {
for (const auto& c : descs) topo.worker_cores.push_back(c.lps[0]);
if (skip_first && !topo.worker_cores.empty()) {
topo.host_core = topo.worker_cores.front();
topo.worker_cores.erase(topo.worker_cores.begin());
}
return topo;
}
// Hybrid CPU with Auto or PCores affinity:
// Prioritize Performance cores:
// 1. Primary logical processor of each P-core (avoids SMT resource contention)
// 2. SMT sibling logical processors of P-cores
// 3. E-cores (only as overflow in Auto mode)
std::vector<int> p_primaries;
std::vector<int> p_siblings;
std::vector<int> e_cores;
for (const auto& c : descs) {
if (c.efficiency == max_eff) {
p_primaries.push_back(c.lps[0]);
for (size_t s = 1; s < c.lps.size(); ++s) {
p_siblings.push_back(c.lps[s]);
}
} else {
for (int lp : c.lps) e_cores.push_back(lp);
}
}
if (skip_first && !p_primaries.empty()) {
topo.host_core = p_primaries.front();
p_primaries.erase(p_primaries.begin());
}
for (int cpu : p_primaries) topo.worker_cores.push_back(cpu);
for (int cpu : p_siblings) topo.worker_cores.push_back(cpu);
if (affinity != PoolAffinity::PCores) {
for (int cpu : e_cores) topo.worker_cores.push_back(cpu);
}
return topo;
}
#else
// The logical CPUs this process may run on, ONE PER PHYSICAL CORE (issue #40): SMT siblings share a core's
// load/store bandwidth, so a worker on each would put two workers on one core, as the Windows branch above
// explains. sysfs names each CPU's (package, core); the first allowed CPU of each pair is kept, so a taskset
// that leaves out the first sibling still gets its core. Without sysfs every allowed CPU counts, as before.
auto topo = [](int cpu, const char* what) -> long {
auto topo_read = [](int cpu, const char* what) -> long {
char path[96];
std::snprintf(path, sizeof path, "/sys/devices/system/cpu/cpu%d/topology/%s", cpu, what);
long v = -1;
@@ -69,6 +150,17 @@ std::vector<int> physical_cores(bool skip_first) {
}
return v;
};
auto cap_read = [](int cpu) -> long {
char path[96];
std::snprintf(path, sizeof path, "/sys/devices/system/cpu/cpu%d/cpu_capacity", cpu);
long v = -1;
if (std::FILE* f = std::fopen(path, "r")) {
if (std::fscanf(f, "%ld", &v) != 1) v = -1;
std::fclose(f);
}
return v;
};
std::vector<int> allowed;
cpu_set_t set;
CPU_ZERO(&set);
@@ -78,19 +170,96 @@ std::vector<int> physical_cores(bool skip_first) {
} else {
for (unsigned i = 0; i < std::thread::hardware_concurrency(); ++i) allowed.push_back((int) i);
}
std::vector<std::pair<long, long>> seen;
struct CoreLinux {
int cpu = -1;
long pkg = -1;
long core = -1;
long cap = -1;
bool is_sibling = false;
};
std::vector<CoreLinux> all_cpus;
std::vector<std::pair<long, long>> seen_phys;
long max_cap = 0, min_cap = 1000000;
for (int cpu : allowed) {
const long pkg = topo(cpu, "physical_package_id"), core = topo(cpu, "core_id");
if (pkg >= 0 && core >= 0) {
const std::pair<long, long> key{pkg, core};
if (std::find(seen.begin(), seen.end(), key) != seen.end()) continue; // an SMT sibling
seen.push_back(key);
CoreLinux cl;
cl.cpu = cpu;
cl.pkg = topo_read(cpu, "physical_package_id");
cl.core = topo_read(cpu, "core_id");
cl.cap = cap_read(cpu);
if (cl.cap > 0) {
max_cap = (std::max)(max_cap, cl.cap);
min_cap = (std::min)(min_cap, cl.cap);
}
cores.push_back(cpu);
if (cl.pkg >= 0 && cl.core >= 0) {
const std::pair<long, long> key{cl.pkg, cl.core};
if (std::find(seen_phys.begin(), seen_phys.end(), key) != seen_phys.end()) {
cl.is_sibling = true;
} else {
seen_phys.push_back(key);
}
}
all_cpus.push_back(cl);
}
topo.is_hybrid = (max_cap > 0 && max_cap > min_cap);
if (topo.is_hybrid) {
for (const auto& cl : all_cpus) {
if (cl.cap == max_cap) {
if (!cl.is_sibling) topo.p_cores++;
topo.p_threads++;
} else {
if (!cl.is_sibling) topo.e_cores++;
}
}
} else {
topo.p_cores = (int) seen_phys.size();
topo.p_threads = (int) all_cpus.size();
}
if (affinity == PoolAffinity::All || !topo.is_hybrid) {
for (const auto& cl : all_cpus) {
if (!cl.is_sibling) topo.worker_cores.push_back(cl.cpu);
}
if (skip_first && !topo.worker_cores.empty()) {
topo.host_core = topo.worker_cores.front();
topo.worker_cores.erase(topo.worker_cores.begin());
}
return topo;
}
// Hybrid CPU on Linux:
std::vector<int> p_primaries;
std::vector<int> p_siblings;
std::vector<int> e_cores;
for (const auto& cl : all_cpus) {
if (cl.cap == max_cap) {
if (!cl.is_sibling) p_primaries.push_back(cl.cpu);
else p_siblings.push_back(cl.cpu);
} else {
e_cores.push_back(cl.cpu);
}
}
if (skip_first && !p_primaries.empty()) {
topo.host_core = p_primaries.front();
p_primaries.erase(p_primaries.begin());
}
for (int cpu : p_primaries) topo.worker_cores.push_back(cpu);
for (int cpu : p_siblings) topo.worker_cores.push_back(cpu);
if (affinity != PoolAffinity::PCores) {
for (int cpu : e_cores) topo.worker_cores.push_back(cpu);
}
return topo;
#endif
if (skip_first && !cores.empty()) cores.erase(cores.begin());
return cores;
return topo;
}
std::vector<int> physical_cores(bool skip_first, PoolAffinity affinity) {
return detect_cpu_topology(skip_first, affinity).worker_cores;
}
namespace {
@@ -173,11 +342,17 @@ void ExpertPool::diag(std::FILE* f) const {
std::fprintf(f, " for %lld ms\n", (long long) (now_ms() - hstate_ms_.load()));
}
ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works) : host_works_(host_works) {
ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works, PoolAffinity affinity)
: host_works_(host_works), affinity_(affinity), topo_(detect_cpu_topology(true, affinity)) {
if (const char* e = std::getenv("STRATA_POOL_SPIN_US")) // a test knob; see kSpinBeforeSleep
spin_before_sleep_ = std::chrono::microseconds((std::max)(0, std::atoi(e)));
const std::vector<int> cores = physical_cores(true);
n_ = n_workers > 0 ? n_workers : (int) cores.size();
if (n_workers > 0) {
n_ = n_workers;
} else if (topo_.is_hybrid && affinity_ != PoolAffinity::All) {
n_ = (std::max)(1, topo_.p_cores - 1);
} else {
n_ = (int) topo_.worker_cores.size();
}
if (n_ < 1) n_ = 1;
scratch_.resize((size_t) n_);
wstate_.reset(new std::atomic<int32_t>[(size_t) n_]);
@@ -189,7 +364,7 @@ ExpertPool::ExpertPool(int n_workers, bool pin, bool host_works) : host_works_(h
split_multi_.resize((size_t) kMaxSplitMulti);
threads_.reserve((size_t) n_);
for (int i = 0; i < n_; ++i) {
const int core = pin ? (i < (int) cores.size() ? cores[(size_t) i] : -1) : -1;
const int core = pin ? (i < (int) topo_.worker_cores.size() ? topo_.worker_cores[(size_t) i] : -1) : -1;
threads_.emplace_back([this, i, core] {
pin_this_thread(core);
worker(i);
+13
View File
@@ -67,6 +67,17 @@ __device__ __forceinline__ void group32(const uint8_t* row_blocks, int gi_in_row
put(out, j, (float) (((b[6 + j] & 0x0F) | xh0) - 16) * d);
put(out, j + 16, (float) (((b[6 + j] >> 4) | xh1) - 16) * d);
}
} else if constexpr (TYPE == 7) { // Q5_1
const uint8_t* b = row_blocks + (size_t) gi_in_row * 24;
const float d = h2f(b), m = h2f(b + 2);
const uint32_t qh = (uint32_t) b[4] | ((uint32_t) b[5] << 8) |
((uint32_t) b[6] << 16) | ((uint32_t) b[7] << 24);
for (int j = 0; j < 16; ++j) {
const int xh0 = ((qh >> j) & 1) << 4;
const int xh1 = ((qh >> (j + 16)) & 1) << 4;
put(out, j, (float) ((b[8 + j] & 15) | xh0) * d + m);
put(out, j + 16, (float) ((b[8 + j] >> 4) | xh1) * d + m);
}
} else if constexpr (TYPE == 8) { // Q8_0
const uint8_t* b = row_blocks + (size_t) gi_in_row * 34;
const float d = h2f(b);
@@ -167,6 +178,7 @@ bool geometry(int type, int& block_elems, int& block_bytes) {
switch (type) {
case 2: block_elems = 32; block_bytes = 18; return true;
case 6: block_elems = 32; block_bytes = 22; return true;
case 7: block_elems = 32; block_bytes = 24; return true;
case 8: block_elems = 32; block_bytes = 34; return true;
case 20: block_elems = 32; block_bytes = 18; return true;
case 11: block_elems = 256; block_bytes = 110; return true;
@@ -195,6 +207,7 @@ void launch(int type, const void* blocks, int64_t row0, int64_t rows, int64_t co
switch (type) {
case 2: STRATA_DQ(2);
case 6: STRATA_DQ(6);
case 7: STRATA_DQ(7);
case 8: STRATA_DQ(8);
case 11: STRATA_DQ(11);
case 12: STRATA_DQ(12);
+502 -81
View File
@@ -1,12 +1,18 @@
// src/kernels/cuda/fused_gr.cu - see include/strata/kernels/fused_gr.hpp.
#include "strata/core/emulate.hpp"
#include "strata/kernels/fused_gr.hpp"
#include "strata/kernels/bf16_bits.hpp"
#include "strata/kernels/verify_kernels.hpp"
#include <cuda_runtime.h>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <string>
#include <vector>
namespace strata::kernels {
namespace {
@@ -489,6 +495,309 @@ __global__ void __launch_bounds__(THREADS) gr_up_v3_kernel(GrMulti m, const floa
m.a[k].mixed[d0 + col] = sum / (float) HC;
}
}
// ================================ #315: split / staged - the same arithmetic, split differently ==================
// The multi read's variants beside the plain read (0.1.31's default; not main's opt-in STRATA_GR_V3 read, which sums
// in another order). Every variant computes each output with exactly the plain read's operations in its order (so
// bitwise the plain read and the single-token read); only who does what, and when, changes. `fused_gr_check`
// compares them on the card before a verify window uses them (STRATA_HC_SPLIT below).
// - the norm (split and staged): one block per token AND stream instead of one per token. Each thread visits
// exactly the elements, in the order, it visited for that stream in the plain read, so every sum of squares and rs
// is the same; then it recomputes its R' * w_norm and writes it times rs (the plain read stored the product and
// scaled it in place: the same two roundings). On an RTX 4080 SUPER, the plain read's norm took 0.58 ms per
// 4-token round (96 launches of 1-4 blocks), this one 0.23 ms.
// - the down projection, split: the plain read's kernel.
// - the down projection, staged: the plain read's 8 rows per block and lane order, but the activations arrive by
// cp.async in half-stream tiles, two in flight, so no thread waits on a chain of loads, and they sit in shared
// memory as two planes (floats 0-3 and 4-7 of every 8), so a warp's reads hit no bank twice. The next tile's
// weights are loaded while the current one is used. A tile is 160 = 5 x 32 chunks of 8, so a lane still
// accumulates its chunks lane + 32 q in ascending order, as in the plain read with either tile. Before sm_80
// (and on HIP) the staging is a plain copy: the same bits, only not asynchronous.
constexpr int kHcPlain = 1, kHcSplit = 2, kHcStaged = 3;
constexpr int H_TILE = 1280; // staged tile: half a stream = 160 chunks of 8, 5 per lane
constexpr int HQ = H_TILE / 8 / 32;
constexpr int N_HTILES = D / H_TILE; // 8
static_assert(N % H_TILE == 0, "a staged tile never straddles two streams");
static_assert(H_TILE % 256 == 0, "a staged tile holds whole rounds of 32 chunks: the plain read's lane order");
__global__ void __launch_bounds__(THREADS) gr_norm_split_kernel(GrMulti m) {
__shared__ float part[WARPS];
__shared__ float s_rs;
const FusedGrArgs& a = m.a[blockIdx.x];
const int c = blockIdx.y;
float* xn = m.xn + (size_t) blockIdx.x * D;
const int t = threadIdx.x, lane = t & 31, warp = t >> 5;
const float gw = a.apply ? 2.0f * sigmoidf_(a.inj_prev[c] / (float) HC) : 0.0f;
float ss = 0.0f;
for (int i = t * 4; i < D; i += THREADS * 4) {
if (i / N != c) continue; // another stream's element: another block's
const int d = i - c * N;
float4 r = *reinterpret_cast<const float4*>(a.R + i);
if (a.apply) {
const float4 b = *reinterpret_cast<const float4*>(a.bo_prev + d);
r.x = fmaf(b.x, gw, r.x); r.y = fmaf(b.y, gw, r.y);
r.z = fmaf(b.z, gw, r.z); r.w = fmaf(b.w, gw, r.w);
}
const float sq = r.x * r.x + r.y * r.y + r.z * r.z + r.w * r.w;
ss += sq;
}
const float v = warp_sum(ss);
if (lane == 0) part[warp] = v;
__syncthreads();
if (t == 0) {
float s = 0.0f;
for (int w = 0; w < WARPS; ++w) s += part[w];
s_rs = rsqrtf(s / (float) N + a.eps);
a.rs[c] = s_rs;
}
__syncthreads();
const float rs = s_rs;
for (int i = t * 4; i < D; i += THREADS * 4) {
if (i / N != c) continue;
const int d = i - c * N;
float4 r = *reinterpret_cast<const float4*>(a.R + i);
if (a.apply) {
const float4 b = *reinterpret_cast<const float4*>(a.bo_prev + d);
r.x = fmaf(b.x, gw, r.x); r.y = fmaf(b.y, gw, r.y);
r.z = fmaf(b.z, gw, r.z); r.w = fmaf(b.w, gw, r.w);
}
const float4 g = *reinterpret_cast<const float4*>(a.w_norm + i);
const float px = r.x * g.x, py = r.y * g.y, pz = r.z * g.z, pw = r.w * g.w;
*reinterpret_cast<float4*>(xn + i) = make_float4(px * rs, py * rs, pz * rs, pw * rs);
}
}
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 800 && !defined(__HIPCC__)
#define STRATA_GR_CP_ASYNC 1
#endif
__device__ __forceinline__ void cp_async16(void* smem, const void* gmem) {
#if defined(STRATA_GR_CP_ASYNC)
const unsigned sa = (unsigned) __cvta_generic_to_shared(smem);
asm volatile("cp.async.cg.shared.global [%0], [%1], 16;\n" ::"r"(sa), "l"(gmem) : "memory");
#else
*reinterpret_cast<float4*>(smem) = *reinterpret_cast<const float4*>(gmem);
#endif
}
__device__ __forceinline__ void cp_async_commit() {
#if defined(STRATA_GR_CP_ASYNC)
asm volatile("cp.async.commit_group;\n" ::: "memory");
#endif
}
__device__ __forceinline__ void cp_async_wait1() {
#if defined(STRATA_GR_CP_ASYNC)
asm volatile("cp.async.wait_group 1;\n" ::: "memory");
#endif
}
__device__ __forceinline__ void cp_async_wait0() {
#if defined(STRATA_GR_CP_ASYNC)
asm volatile("cp.async.wait_group 0;\n" ::: "memory");
#endif
}
// `dot8` with its 8 activations as two float4: the same eight fmaf in the same order
__device__ __forceinline__ float dot8v(const uint4 w, const float4 x0, const float4 x1) {
float acc = 0.0f;
acc = fmaf(__uint_as_float(w.x << 16), x0.x, acc);
acc = fmaf(__uint_as_float(w.x & 0xffff0000u), x0.y, acc);
acc = fmaf(__uint_as_float(w.y << 16), x0.z, acc);
acc = fmaf(__uint_as_float(w.y & 0xffff0000u), x0.w, acc);
acc = fmaf(__uint_as_float(w.z << 16), x1.x, acc);
acc = fmaf(__uint_as_float(w.z & 0xffff0000u), x1.y, acc);
acc = fmaf(__uint_as_float(w.w << 16), x1.z, acc);
acc = fmaf(__uint_as_float(w.w & 0xffff0000u), x1.w, acc);
return acc;
}
// Stage tile `h` of every token into `buf`: [T][2 planes][160 chunks] float4, plane 0 = floats 0-3 of a chunk.
__device__ __forceinline__ void stage_htile(const GrMulti& m, int T, int h, float4* buf, int t) {
for (int i = t; i < T * (H_TILE / 4); i += THREADS) {
const int k = i / (H_TILE / 4), s4 = i - k * (H_TILE / 4); // s4: float4 of the tile, chunk s4/2, half s4&1
const float* src = m.xn + (size_t) k * D + (size_t) h * H_TILE + (size_t) s4 * 4;
cp_async16(buf + (size_t) k * (H_TILE / 4) + (s4 & 1) * (H_TILE / 8) + (s4 >> 1), src);
}
}
__global__ void __launch_bounds__(THREADS) gr_down_staged_kernel(GrMulti m) {
extern __shared__ __align__(16) float4 hbuf[]; // 2 buffers x [T][2][160] float4
const int t = threadIdx.x, lane = t & 31, warp = t >> 5;
const int T = m.T;
const bool inject_block = blockIdx.x == DOWN_BLOCKS;
const int row = inject_block ? warp : blockIdx.x * WARPS + warp;
const bool active = !(inject_block && (m.a[0].w_inject == nullptr || warp >= HC));
const uint16_t* wrow = (inject_block ? m.a[0].w_inject : m.a[0].w_down) + (size_t) (active ? row : 0) * D;
const uint4* w4 = reinterpret_cast<const uint4*>(wrow);
const size_t buf_f4 = (size_t) T * (H_TILE / 4); // float4 per buffer (the second one follows the first)
float acc[kFusedGrMaxT];
#pragma unroll
for (int k = 0; k < kFusedGrMaxT; ++k) acc[k] = 0.0f;
uint4 wv[HQ], wnext[HQ];
if (active) {
#pragma unroll
for (int q = 0; q < HQ; ++q) wv[q] = __ldg(w4 + lane + 32 * q);
}
stage_htile(m, T, 0, hbuf, t);
cp_async_commit();
stage_htile(m, T, 1, hbuf + buf_f4, t);
cp_async_commit();
#pragma unroll 1
for (int h = 0; h < N_HTILES; ++h) {
if (active && h + 1 < N_HTILES) {
#pragma unroll
for (int q = 0; q < HQ; ++q) wnext[q] = __ldg(w4 + (h + 1) * (H_TILE / 8) + lane + 32 * q);
}
if (h + 1 < N_HTILES) cp_async_wait1(); // tile h has landed (h + 1 may still be on its way)
else cp_async_wait0();
__syncthreads();
const float4* cur = hbuf + (h & 1) * buf_f4;
if (active) {
#pragma unroll
for (int q = 0; q < HQ; ++q) {
const int j = lane + 32 * q;
#pragma unroll
for (int k = 0; k < kFusedGrMaxT; ++k) {
if (k < T) {
const float4* pk = cur + (size_t) k * (H_TILE / 4);
acc[k] += dot8v(wv[q], pk[j], pk[H_TILE / 8 + j]);
}
}
}
}
__syncthreads(); // every warp is done with buffer h & 1
if (h + 2 < N_HTILES) stage_htile(m, T, h + 2, hbuf + (h & 1) * buf_f4, t);
cp_async_commit(); // an empty group at the end keeps the wait counts simple
if (h + 1 < N_HTILES) {
#pragma unroll
for (int q = 0; q < HQ; ++q) wv[q] = wnext[q];
}
}
if (!active) return;
float s[kFusedGrMaxT];
#pragma unroll
for (int k = 0; k < kFusedGrMaxT; ++k) s[k] = k < T ? warp_sum(acc[k]) : 0.0f;
// lane k writes token k (every lane holds every sum after the xor reduction)
#pragma unroll
for (int k = 0; k < kFusedGrMaxT; ++k) {
if (k >= T || lane != k) continue;
if (inject_block) {
m.a[k].inject_out[row] = s[k];
} else {
const float x = s[k] / (float) HC;
m.a[k].lo[row] = x / (1.0f + __expf(-x));
}
}
}
// The tokens a down kernel may carry in one launch on the current card, and the plain read's tile: the plain read
// stages n_tok * TILEV floats (1280 on sm_75, 2560 elsewhere), staged two tiles of n_tok * H_TILE. The shared-memory
// opt-in is set here once per device (a per-DEVICE setting: a layer split runs these kernels on two cards).
int down_chunk(bool staged, int* tile_out) {
static bool attr[64] = {};
static int chunk[64] = {}; // tokens the plain down kernel may carry in one launch on this card
static int chunk_staged[64] = {}; // the same for staged
static int tile[64] = {}; // the plain down kernel's TILEV on this card (1280 on sm_75, 2560 elsewhere)
int dev = 0;
cudaGetDevice(&dev);
if (dev < 0 || dev >= 64) {
*tile_out = 2560;
return kFusedGrMaxT;
}
if (!attr[dev]) {
int optin = 0;
cudaDeviceGetAttribute(&optin, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev);
optin = strata::smem_optin_of(optin); // STRATA_EMULATE_CC (tests only)
// the down kernel stages n_tok*TILEV floats of dynamic shared memory - 80 KB at the full 8 tokens of the
// CUDA tile. sm_75 gets the smaller tile: all eight tokens fit one 40 KiB launch there (no more slicing),
// the TQ-5 prefetch holds half the registers, and the smaller blocks raise how many of the 41-block grid
// share an SM. Cards whose opt-in is still below that (or that report no opt-in at all) slice the tokens;
// the down kernel's outputs (lo, inject_out) are strictly per-token, so the chunk boundaries are safe, and
// the up kernel below still sees every token of the batch in one launch.
#if defined(__HIPCC__)
const bool small_tile = true; // all eight tokens fit gfx1100's 64 KiB LDS at this tile
#else
int cc_maj = 0, cc_min = 0;
cudaDeviceGetAttribute(&cc_maj, cudaDevAttrComputeCapabilityMajor, dev);
cudaDeviceGetAttribute(&cc_min, cudaDevAttrComputeCapabilityMinor, dev);
const bool small_tile = strata::cc_major_of(cc_maj) * 10 + strata::cc_minor_of(cc_min) == 75;
#endif
const int tv = small_tile ? 1280 : 2560;
tile[dev] = tv;
int want = (int) (kFusedGrMaxT * tv * sizeof(float));
if (optin > 0 && want > optin) want = optin;
if (small_tile) {
cudaFuncSetAttribute(gr_down_multi_kernel<1280>, cudaFuncAttributeMaxDynamicSharedMemorySize, want);
} else {
cudaFuncSetAttribute(gr_down_multi_kernel<2560>, cudaFuncAttributeMaxDynamicSharedMemorySize, want);
}
int want_staged = (int) (2 * kFusedGrMaxT * H_TILE * sizeof(float));
if (optin > 0 && want_staged > optin) want_staged = optin;
cudaFuncSetAttribute(gr_down_staged_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, want_staged);
cudaGetLastError(); // drop any error the attempt left behind
// The opt-in is a promise a pre-Volta card does not keep: an sm_60 answers 65536 and accepts the
// cudaFuncSetAttribute for 61440 B, then fails the LAUNCH with "invalid argument". What such a card
// will launch is its per-block limit, so the capacity comes from that below sm_70 - the same tokens
// the "no opt-in" branch assumes, but taken from the attribute that is actually enforced.
#if defined(__HIPCC__)
const int usable = optin > 0 ? optin : 48 * 1024;
#else
int cc = 0, per_block = 0;
cudaDeviceGetAttribute(&cc, cudaDevAttrComputeCapabilityMajor, dev);
cudaDeviceGetAttribute(&per_block, cudaDevAttrMaxSharedMemoryPerBlock, dev);
cc = strata::cc_major_of(cc);
const int usable = (cc >= 7 && optin > 0) ? optin : per_block;
#endif
const int capacity = usable / (int) (tv * sizeof(float));
chunk[dev] = capacity < 1 ? 1 : (capacity > kFusedGrMaxT ? kFusedGrMaxT : capacity);
const int capacity_staged = usable / (int) (2 * H_TILE * sizeof(float));
chunk_staged[dev] = capacity_staged < 1 ? 1 : (capacity_staged > kFusedGrMaxT ? kFusedGrMaxT : capacity_staged);
attr[dev] = true;
}
*tile_out = tile[dev] ? tile[dev] : 2560;
return staged ? chunk_staged[dev] : chunk[dev];
}
// The multi read as `variant` (kHcPlain, kHcSplit or kHcStaged): the norm, the down projection in launches of as
// many tokens as fit the card, the up projection; the profile's stamps after the norm and after the down projection.
// kHcPlain is the default read exactly as before (never main's opt-in STRATA_GR_V3 path, which fused_gr_read_multi
// takes first).
void launch_multi(const GrMulti& m, int variant, cudaStream_t st, unsigned long long* stamp_buf, int stamp_i0) {
const int n_tok = m.T;
if (variant >= kHcSplit) gr_norm_split_kernel<<<dim3((unsigned) n_tok, HC), THREADS, 0, st>>>(m);
else gr_norm_multi_kernel<<<n_tok, THREADS, 0, st>>>(m);
if (stamp_buf) gpu_stamp(stamp_buf, stamp_i0, (void*) st);
const bool staged = variant >= kHcStaged;
int tv = 2560;
const int chunk_tok = down_chunk(staged, &tv);
const size_t per_tok = (staged ? (size_t) 2 * H_TILE : (size_t) tv) * sizeof(float);
for (int c0 = 0; c0 < n_tok; c0 += chunk_tok) {
const int ct = n_tok - c0 < chunk_tok ? n_tok - c0 : chunk_tok;
GrMulti c{};
if (ct == n_tok) {
c = m;
} else {
c.xn = m.xn + (size_t) c0 * D;
c.T = ct;
for (int k = 0; k < ct; ++k) c.a[k] = m.a[c0 + k];
}
const size_t smem = (size_t) ct * per_tok;
if (staged) gr_down_staged_kernel<<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(c);
else if (tv == 1280) gr_down_multi_kernel<1280><<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(c);
else gr_down_multi_kernel<2560><<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(c);
}
if (stamp_buf) gpu_stamp(stamp_buf, stamp_i0 + 1, (void*) st);
gr_up_multi_kernel<<<UPM_BLOCKS, THREADS, 0, st>>>(m);
}
/// STRATA_HC_SPLIT: unset or 2 = the newest the check accepts (staged), 1 = at most split, 0 = the plain read
int env_variant() {
const char* e = std::getenv("STRATA_HC_SPLIT");
if (e == nullptr || e[0] == '\0') return kHcStaged;
if (e[0] == '0') return kHcPlain;
if (e[0] == '1') return kHcSplit;
return kHcStaged;
}
// per device: the variant `fused_gr_check` chose (0 = not checked yet)
std::atomic<int> g_variant[64];
} // namespace
void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, void* stream, unsigned long long* stamp_buf,
@@ -526,9 +835,11 @@ void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, voi
if (need1 <= limit &&
cudaFuncSetAttribute(gr_down_v3_kernel<1>, cudaFuncAttributeMaxDynamicSharedMemorySize, need1) == cudaSuccess)
split = 1;
else if (need2 <= limit &&
cudaFuncSetAttribute(gr_down_v3_kernel<2>, cudaFuncAttributeMaxDynamicSharedMemorySize, need2) == cudaSuccess)
split = 2;
else if (need2 <= limit)
// #375 (kenh0u): the S = 2 split (a 64 KB opt-in card: Turing) disagrees with itself in gr_parity
// (graph replay vs direct call) - such a card keeps the default read until that split is fixed
std::fprintf(stderr, "strata: STRATA_GR_V3=1 needs the two-half split on this card, which fails its "
"checks (#375): the default read is used\n");
cudaGetLastError(); // drop any error the attempts left behind
split3[dev3] = split;
}
@@ -551,84 +862,8 @@ void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, voi
}
return;
}
gr_norm_multi_kernel<<<n_tok, THREADS, 0, st>>>(m);
if (stamp_buf) gpu_stamp(stamp_buf, stamp_i0, stream);
// the shared-memory opt-in is a per-DEVICE setting: once per device, not once per process (a layer split
// runs this kernel on two cards)
static bool attr[64] = {};
static int chunk[64] = {}; // tokens the down kernel may carry in one launch on this card
static int tile[64] = {}; // the down kernel's TILEV on this card (1280 on sm_75, 2560 elsewhere; see above)
int dev = 0;
cudaGetDevice(&dev);
if (dev >= 0 && dev < 64 && !attr[dev]) {
int optin = 0;
cudaDeviceGetAttribute(&optin, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev);
// the down kernel stages n_tok*TILEV floats of dynamic shared memory - 80 KB at the full 8 tokens of the
// CUDA tile. sm_75 gets the smaller tile: all eight tokens fit one 40 KiB launch there (no more slicing),
// the TQ-5 prefetch holds half the registers, and the smaller blocks raise how many of the 41-block grid
// share an SM. Cards whose opt-in is still below that (or that report no opt-in at all) slice the tokens;
// the down kernel's outputs (lo, inject_out) are strictly per-token, so the chunk boundaries are safe, and
// the up kernel below still sees every token of the batch in one launch.
#if defined(__HIPCC__)
const bool small_tile = true; // all eight tokens fit gfx1100's 64 KiB LDS at this tile
#else
int cc_maj = 0, cc_min = 0;
cudaDeviceGetAttribute(&cc_maj, cudaDevAttrComputeCapabilityMajor, dev);
cudaDeviceGetAttribute(&cc_min, cudaDevAttrComputeCapabilityMinor, dev);
const bool small_tile = cc_maj * 10 + cc_min == 75;
#endif
const int tv = small_tile ? 1280 : 2560;
tile[dev] = tv;
int want = (int) (kFusedGrMaxT * tv * sizeof(float));
if (optin > 0 && want > optin) want = optin;
if (small_tile) {
cudaFuncSetAttribute(gr_down_multi_kernel<1280>, cudaFuncAttributeMaxDynamicSharedMemorySize, want);
} else {
cudaFuncSetAttribute(gr_down_multi_kernel<2560>, cudaFuncAttributeMaxDynamicSharedMemorySize, want);
}
cudaGetLastError(); // drop any error the attempt left behind
// The opt-in is a promise a pre-Volta card does not keep: an sm_60 answers 65536 and accepts the
// cudaFuncSetAttribute for 61440 B, then fails the LAUNCH with "invalid argument". What such a card
// will launch is its per-block limit, so the capacity comes from that below sm_70 - the same tokens
// the "no opt-in" branch assumes, but taken from the attribute that is actually enforced.
#if defined(__HIPCC__)
const int usable = optin > 0 ? optin : 48 * 1024;
#else
int cc = 0, per_block = 0;
cudaDeviceGetAttribute(&cc, cudaDevAttrComputeCapabilityMajor, dev);
cudaDeviceGetAttribute(&per_block, cudaDevAttrMaxSharedMemoryPerBlock, dev);
const int usable = (cc >= 7 && optin > 0) ? optin : per_block;
#endif
const int capacity = usable / (int) (tv * sizeof(float));
chunk[dev] = capacity < 1 ? 1 : (capacity > kFusedGrMaxT ? kFusedGrMaxT : capacity);
attr[dev] = true;
}
const int tv = (dev >= 0 && dev < 64 && tile[dev]) ? tile[dev] : 2560;
const int chunk_tok = (dev >= 0 && dev < 64 && chunk[dev]) ? chunk[dev] : kFusedGrMaxT;
if (chunk_tok >= n_tok) {
const size_t smem = (size_t) n_tok * tv * sizeof(float);
if (tv == 1280) {
gr_down_multi_kernel<1280><<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(m);
} else {
gr_down_multi_kernel<2560><<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(m);
}
} else {
for (int c0 = 0; c0 < n_tok; c0 += chunk_tok) {
const int ct = n_tok - c0 < chunk_tok ? n_tok - c0 : chunk_tok;
GrMulti c{};
c.xn = xn_scratch + (size_t) c0 * D;
c.T = ct;
for (int k = 0; k < ct; ++k) c.a[k] = a[c0 + k];
const size_t smem = (size_t) ct * tv * sizeof(float);
if (tv == 1280) {
gr_down_multi_kernel<1280><<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(c);
} else {
gr_down_multi_kernel<2560><<<DOWN_BLOCKS + 1, THREADS, smem, st>>>(c);
}
}
}
if (stamp_buf) gpu_stamp(stamp_buf, stamp_i0 + 1, stream);
gr_up_multi_kernel<<<UPM_BLOCKS, THREADS, 0, st>>>(m);
// the default read (STRATA_GR_V3 unset): v1, or the bitwise-equal v2 / v3 this card's check accepted
launch_multi(m, fused_gr_variant(), st, stamp_buf, stamp_i0);
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
std::fprintf(stderr, "fused_gr_read_multi: %s\n", cudaGetErrorString(e));
@@ -657,4 +892,190 @@ void fused_gr_read(const FusedGrArgs& a, void* stream) {
}
}
namespace {
/// The plain read, split, staged and (for one token) the single-token read on random bf16 weights and random inputs,
/// 1..8 tokens, with and without the pending write; every output of split and staged compared with the plain read's
/// bit for bit (and the plain read's with the single-token read's). `why[v]` gets the first difference of variant
/// v; false if the check itself could not run.
bool fused_gr_selftest(bool ok_variant[4], std::string why[4]) {
constexpr int TM = kFusedGrMaxT;
constexpr int NV = 4; // sets: 0 = plain, 1 = split, 2 = staged, 3 = single-token
std::mt19937 rng(20260930u);
std::normal_distribution<float> nd(0.0f, 1.0f);
std::vector<uint16_t> h_down((size_t) LR * D), h_up((size_t) D * LR), h_inj((size_t) HC * D);
std::vector<float> h_norm(D), h_R((size_t) TM * D), h_bo((size_t) TM * N), h_ip((size_t) TM * HC);
for (auto& w : h_down) w = bf16_from_f32(0.02f * nd(rng));
for (auto& w : h_up) w = bf16_from_f32(0.05f * nd(rng));
for (auto& w : h_inj) w = bf16_from_f32(0.02f * nd(rng));
for (auto& w : h_norm) w = 1.0f + 0.1f * nd(rng);
for (auto& x : h_R) x = nd(rng);
for (auto& x : h_bo) x = 0.5f * nd(rng);
for (auto& x : h_ip) x = 2.0f * nd(rng);
for (int v = 0; v < 4; ++v) { ok_variant[v] = v == 1; why[v].clear(); }
const size_t n_set = (size_t) TM * (D + D + LR + HC + HC + N); // R_out, xn, lo, rs, inject, mixed (floats)
const size_t bytes = h_down.size() * 2 + h_up.size() * 2 + h_inj.size() * 2 +
(h_norm.size() + h_R.size() + h_bo.size() + h_ip.size() + NV * n_set) * 4 + 32 * 256;
uint8_t* base = nullptr;
if (cudaMalloc((void**) &base, bytes) != cudaSuccess) {
cudaGetLastError();
why[0] = "no room for the check (" + std::to_string(bytes >> 20) + " MiB)";
return false;
}
size_t off = 0;
auto take = [&](size_t b) { void* p = base + off; off += (b + 255) / 256 * 256; return p; };
uint16_t* d_down = (uint16_t*) take(h_down.size() * 2);
uint16_t* d_up = (uint16_t*) take(h_up.size() * 2);
uint16_t* d_inj = (uint16_t*) take(h_inj.size() * 2);
float* d_norm = (float*) take(h_norm.size() * 4);
float* d_R = (float*) take(h_R.size() * 4);
float* d_bo = (float*) take(h_bo.size() * 4);
float* d_ip = (float*) take(h_ip.size() * 4);
struct Set { float *R_out, *xn, *lo, *rs, *inj, *mixed; } set[NV];
for (Set& x : set) {
x.R_out = (float*) take((size_t) TM * D * 4); x.xn = (float*) take((size_t) TM * D * 4);
x.lo = (float*) take((size_t) TM * LR * 4); x.rs = (float*) take((size_t) TM * HC * 4);
x.inj = (float*) take((size_t) TM * HC * 4); x.mixed = (float*) take((size_t) TM * N * 4);
}
cudaStream_t st = nullptr;
bool ok = off <= bytes && cudaStreamCreateWithFlags(&st, cudaStreamNonBlocking) == cudaSuccess &&
cudaMemcpyAsync(d_down, h_down.data(), h_down.size() * 2, cudaMemcpyHostToDevice, st) == cudaSuccess &&
cudaMemcpyAsync(d_up, h_up.data(), h_up.size() * 2, cudaMemcpyHostToDevice, st) == cudaSuccess &&
cudaMemcpyAsync(d_inj, h_inj.data(), h_inj.size() * 2, cudaMemcpyHostToDevice, st) == cudaSuccess &&
cudaMemcpyAsync(d_norm, h_norm.data(), h_norm.size() * 4, cudaMemcpyHostToDevice, st) == cudaSuccess &&
cudaMemcpyAsync(d_R, h_R.data(), h_R.size() * 4, cudaMemcpyHostToDevice, st) == cudaSuccess &&
cudaMemcpyAsync(d_bo, h_bo.data(), h_bo.size() * 4, cudaMemcpyHostToDevice, st) == cudaSuccess &&
cudaMemcpyAsync(d_ip, h_ip.data(), h_ip.size() * 4, cudaMemcpyHostToDevice, st) == cudaSuccess;
if (!ok) why[0] = "setting up the check failed";
std::vector<float> h1, h2;
// true when equal; false with the first difference in `w` (or a read-back failure in `ok`)
auto same = [&](const float* d1, const float* d2, size_t n, const char* what, int T, int apply, std::string& w) {
h1.resize(n);
h2.resize(n);
if (cudaMemcpyAsync(h1.data(), d1, n * 4, cudaMemcpyDeviceToHost, st) != cudaSuccess ||
cudaMemcpyAsync(h2.data(), d2, n * 4, cudaMemcpyDeviceToHost, st) != cudaSuccess ||
cudaStreamSynchronize(st) != cudaSuccess) {
why[0] = "reading back the check failed";
ok = false;
return false;
}
for (size_t i = 0; i < n; ++i) {
uint32_t u1, u2;
std::memcpy(&u1, &h1[i], 4);
std::memcpy(&u2, &h2[i], 4);
if (u1 != u2) {
char buf[192];
std::snprintf(buf, sizeof buf, "%s differs for %d token(s)%s at %zu (%08x, not %08x)", what, T,
apply ? " with the pending write" : "", i, u2, u1);
w = buf;
return false;
}
}
return true;
};
auto all_same = [&](const Set& s1, const Set& s2, int T, int apply, std::string& w) {
return same(s1.lo, s2.lo, (size_t) T * LR, "lo", T, apply, w) &&
same(s1.rs, s2.rs, (size_t) T * HC, "rs", T, apply, w) &&
same(s1.inj, s2.inj, (size_t) T * HC, "inject", T, apply, w) &&
same(s1.mixed, s2.mixed, (size_t) T * N, "mixed", T, apply, w) &&
same(s1.R_out, s2.R_out, (size_t) T * D, "R", T, apply, w);
};
ok_variant[2] = ok_variant[3] = ok;
bool single_ok = ok;
for (int apply = 0; apply < 2 && ok; ++apply) {
for (int T = 1; T <= TM && ok; ++T) {
FusedGrArgs a[NV][TM];
for (int v = 0; v < NV; ++v) {
const size_t span = (size_t) ((uint8_t*) (set[v].mixed + (size_t) TM * N) - (uint8_t*) set[v].R_out);
ok = ok && cudaMemsetAsync(set[v].R_out, 0xFF, span, st) == cudaSuccess; // the whole set
for (int k = 0; k < T; ++k) {
FusedGrArgs& x = a[v][k];
x.R = d_R + (size_t) k * D; x.R_out = set[v].R_out + (size_t) k * D; x.apply = apply != 0;
x.bo_prev = d_bo + (size_t) k * N; x.inj_prev = d_ip + (size_t) k * HC;
x.w_norm = d_norm; x.w_down = d_down; x.w_up = d_up; x.w_inject = d_inj; x.eps = 1e-6f;
x.lo = set[v].lo + (size_t) k * LR; x.rs = set[v].rs + (size_t) k * HC;
x.inject_out = set[v].inj + (size_t) k * HC; x.mixed = set[v].mixed + (size_t) k * N;
}
}
for (int v = 0; v < 3; ++v) {
GrMulti m;
for (int k = 0; k < T; ++k) m.a[k] = a[v][k];
m.xn = set[v].xn;
m.T = T;
launch_multi(m, v + 1, st, nullptr, 0);
}
if (T == 1) fused_gr_read(a[3][0], st);
if (cudaGetLastError() != cudaSuccess || cudaStreamSynchronize(st) != cudaSuccess) {
why[0] = "a kernel of the check failed";
ok = false;
break;
}
if (ok_variant[2] && !all_same(set[0], set[1], T, apply, why[2])) ok_variant[2] = false;
if (ok && ok_variant[3] && !all_same(set[0], set[2], T, apply, why[3])) ok_variant[3] = false;
if (ok && T == 1 && single_ok && !all_same(set[3], set[0], T, apply, why[1])) single_ok = false;
}
}
if (!single_ok && ok) { // the plain read itself disagrees with the single-token read
why[2] = why[3] = "the plain read differs from the single-token read: " + why[1];
ok_variant[2] = ok_variant[3] = false;
}
if (st != nullptr) {
cudaStreamSynchronize(st);
cudaStreamDestroy(st);
}
cudaFree(base);
cudaGetLastError();
if (!ok) ok_variant[2] = ok_variant[3] = false;
return ok;
}
} // namespace
int fused_gr_variant() {
int dev = 0;
cudaGetDevice(&dev);
const int v = dev >= 0 && dev < 64 ? g_variant[dev].load() : 0;
if (v > 0) return v;
// not checked on this card: the plain read, unless STRATA_HC_SPLIT names a variant (a test such as gr_parity)
const char* e = std::getenv("STRATA_HC_SPLIT");
return e != nullptr && (e[0] == '1' || e[0] == '2') ? env_variant() : kHcPlain;
}
void fused_gr_check() {
int dev = 0;
cudaGetDevice(&dev);
if (dev < 0 || dev >= 64 || g_variant[dev].load() > 0) return;
const int want = env_variant();
if (want == kHcPlain) {
g_variant[dev].store(kHcPlain);
std::fprintf(stderr, "strata hc: CUDA%d: the hyper-connection read runs as the plain one (STRATA_HC_SPLIT=0)\n",
dev);
return;
}
bool okv[4];
std::string why[4];
const bool ran = fused_gr_selftest(okv, why);
int use = kHcPlain;
if (want >= kHcStaged && okv[kHcStaged]) use = kHcStaged;
else if (okv[kHcSplit]) use = kHcSplit;
g_variant[dev].store(use);
if (!ran)
std::fprintf(stderr, "strata hc: CUDA%d: the check of split/staged could not run (%s)\n", dev, why[0].c_str());
static const char* const name[4] = {"", "plain", "split", "staged"};
for (int v = kHcStaged; v >= kHcSplit; --v)
if (v <= want && !okv[v] && ran)
std::fprintf(stderr, "strata hc: CUDA%d: the %s read differs from the plain read on this card - not used: "
"%s\n", dev, name[v], why[v].c_str());
static const char* const what[4] = {
"", "the plain read (the norm per token, the down projection on 41 blocks)",
"split (the norm per token and stream, then the plain read's down projection)",
"staged (the norm per token and stream, the down projection's activations staged ahead by cp.async)"};
std::fprintf(stderr, "strata hc: CUDA%d: the hyper-connection read runs as %s%s\n", dev, what[use],
use >= kHcSplit ? "; checked bit for bit against the plain read on this card (STRATA_HC_SPLIT=1 or 0 "
"for the earlier ones)" : "");
}
} // namespace strata::kernels
+14 -3
View File
@@ -1218,7 +1218,15 @@ __device__ void dq_q8_0(const void* vx, int64_t ibs, dst_t* yy, int tid) {
for (int j = 0; j < 8; ++j) y[j] = cvt<dst_t>((float) x[ib].qs[8 * il + j] * d);
}
// Every type below must also be in is_iq(): the host entry points refuse the others, so the default is unreachable.
// BF16 (the token embedding as the checkpoint ships it, tools/embd_bf16_pack.py): 8 values per thread.
template<typename dst_t>
__device__ void dq_bf16(const void* vx, int64_t ibs, dst_t* yy, int tid) {
const uint16_t* x = (const uint16_t*) vx + ibs * 256 + tid * 8;
for (int j = 0; j < 8; ++j) yy[tid * 8 + j] = cvt<dst_t>(__uint_as_float((uint32_t) x[j] << 16));
}
// Every type below must also be in is_iq() (BF16: embed_type_supported): the host entry points refuse the others,
// so the default is unreachable.
template<typename dst_t>
__device__ __forceinline__ void dq_dispatch(int ty, const void* vx, int64_t ibs, dst_t* y, int tid) {
switch (ty) {
@@ -1236,6 +1244,7 @@ __device__ __forceinline__ void dq_dispatch(int ty, const void* vx, int64_t ibs,
case 13: dq_q5_k(vx, ibs, y, tid); break;
case 7: dq_q5_1(vx, ibs, y, tid); break;
case 8: dq_q8_0(vx, ibs, y, tid); break;
case 30: dq_bf16(vx, ibs, y, tid); break;
default: break;
}
}
@@ -1321,6 +1330,7 @@ void iq_set_old_kernels(bool old) { g_old_kernels = old; }
bool iq_old_kernels() { return g_old_kernels; }
bool iq_supported(int t) noexcept { return is_iq(t); }
bool embed_type_supported(int t) noexcept { return is_iq(t) || t == 30; }
size_t iq_row_bytes(int t, int64_t n) noexcept {
switch (t) {
@@ -1338,6 +1348,7 @@ size_t iq_row_bytes(int t, int64_t n) noexcept {
case 13: return (size_t) (n / 256) * sizeof(block_q5_K);
case 7: return (size_t) (n / 32) * sizeof(block_q5_1);
case 8: return (size_t) (n / 32) * sizeof(block_q8_0);
case 30: return (size_t) n * 2; // BF16: the token embedding only (iq_embed_rows, iq_dequant_f32)
default: return 0;
}
}
@@ -1382,14 +1393,14 @@ __global__ void embed_rows_kernel(int ty, const uint8_t* __restrict__ table, siz
void iq_embed_rows(int t, const void* table, size_t row_bytes, const int32_t* tokens, int64_t n_tok, int64_t n_embd,
float* out, void* stream) {
if (n_tok <= 0) return;
if (n_embd % 256 != 0 || !is_iq(t)) { std::fprintf(stderr, "iq_embed_rows: bad arguments\n"); std::exit(1); }
if (n_embd % 256 != 0 || !embed_type_supported(t)) { std::fprintf(stderr, "iq_embed_rows: bad arguments\n"); std::exit(1); }
embed_rows_kernel<<<dim3((unsigned) (n_embd / 256), (unsigned) n_tok), 32, 0, (cudaStream_t) stream>>>(
t, (const uint8_t*) table, row_bytes, tokens, n_embd, out);
check("iq_embed_rows");
}
void iq_dequant_f32(int t, const void* src, int64_t n, float* dst, void* stream) {
if (n % 256 != 0 || !is_iq(t)) { std::fprintf(stderr, "iq_dequant_f32: bad arguments\n"); std::exit(1); }
if (n % 256 != 0 || !embed_type_supported(t)) { std::fprintf(stderr, "iq_dequant_f32: bad arguments\n"); std::exit(1); }
dequant_flat_kernel<float><<<(unsigned) (n / 256), 32, 0, (cudaStream_t) stream>>>(t, src, dst);
check("iq_dequant_f32");
}
+1 -1
View File
@@ -92,7 +92,7 @@ __global__ void gate_softplus(const float* __restrict__ alpha, const float* __re
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= count) return;
const float value = __fadd_rn(alpha[i], dt[i]);
const float softplus = value > 20.0f ? value : logf(1.0f + expf(value));
const float softplus = value > 20.0f ? value : log1pf(expf(value)); // 1 + e^v loses e^v below ~1e-7
gate[i] = softplus * ssm_a[i];
}
+3 -1
View File
@@ -1439,7 +1439,7 @@ void native_iq4_nl_f32(const void* weights, const float* x, void* scratch_q8_1,
}
bool native_mmvq_supported(int ggml_type) noexcept {
return ggml_type == 2 || ggml_type == 6 || ggml_type == 8 || ggml_type == 11 ||
return ggml_type == 2 || ggml_type == 6 || ggml_type == 7 || ggml_type == 8 || ggml_type == 11 ||
ggml_type == 12 || ggml_type == 13 || ggml_type == 14 || ggml_type == 20 ||
ggml_type == 23 || ggml_type == 42 || ggml_type == 16 || ggml_type == 17 || ggml_type == 18 ||
ggml_type == 21 || ggml_type == 22 || ggml_type == 29;
@@ -1450,6 +1450,7 @@ std::size_t native_mmvq_weight_bytes(int ggml_type, int n_in, int n_out) {
switch (ggml_type) {
case 2: block_elems = 32; block_bytes = 18; break;
case 6: block_elems = 32; block_bytes = 22; break;
case 7: block_elems = 32; block_bytes = 24; break;
case 8: block_elems = 32; block_bytes = 34; break;
case 20: block_elems = 32; block_bytes = 18; break;
case 11: block_elems = 256; block_bytes = 110; break;
@@ -1476,6 +1477,7 @@ void native_mmvq(int ggml_type, const void* weights, const void* x_q8_1, float*
switch (ggml_type) {
case 2: native_q4_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break;
case 6: native_q5_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break;
case 7: iq_mmvq(ggml_type, weights, x_q8_1, y, n_in, n_out, ncols, stream); break;
case 8: native_q8_0_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break;
case 20: native_iq4_nl_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break;
case 11: native_q3_k_mmvq(weights, x_q8_1, y, n_in, n_out, ncols, stream); break;
+48 -21
View File
@@ -39,13 +39,17 @@ __device__ float warp_sum(float x) {
x += __shfl_xor_sync(0xffffffffu, x, offset);
return x;
}
// TAB (#280, STRATA_ROPE_TABLE=1): the angles from the session's float64 table. The host launches <false> whenever
// no table applies - the default - so the default kernel is 0.1.31's code exactly (the table read is not in it;
// with it merely skipped at run time, the compiled default path changed its results).
template <bool TAB>
__global__ void append(const float* __restrict__ raw, const int32_t* __restrict__ pos_dev,
int pos_base, const float* __restrict__ gamma, float epsilon,
float* __restrict__ tail, float* __restrict__ dead,
float* __restrict__ pooled, int32_t* __restrict__ block_pos,
int max_cells, float theta_scale, float freq_scale, float corr_low,
float corr_high, float ext_factor, float mscale,
const int32_t* __restrict__ mtab) {
const int32_t* __restrict__ mtab, RopeTab rt) {
const int pos = *pos_dev, d = threadIdx.x;
if (pos < 0 || pos >= max_cells) return;
const int slot = pos % R;
@@ -87,9 +91,11 @@ __global__ void append(const float* __restrict__ raw, const int32_t* __restrict_
const int pair = d % (ROT / 2);
// The spare (pos == 0) keeps its zero angle; under YaRN the mscale magnitude still rides in
// through cos(0) - which is exactly what the queries are scaled by, so the top-k is unmoved.
const float theta_extrap = (pos == 0 ? 0 : mrope_pos(mtab, rope_pos, pair)) * powf(theta_scale, float(pair));
float c, s;
rope_scaled_angle(theta_extrap, freq_scale, corr_low, corr_high, ext_factor, mscale, pair, c, s);
if (!(TAB && rope_tab_cs(rt, pos == 0 ? 0 : mrope_pos(mtab, rope_pos, pair), pair, c, s))) {
const float theta_extrap = (pos == 0 ? 0 : mrope_pos(mtab, rope_pos, pair)) * powf(theta_scale, float(pair));
rope_scaled_angle(theta_extrap, freq_scale, corr_low, corr_high, ext_factor, mscale, pair, c, s);
}
const float a = values[pair], z = values[pair + ROT / 2];
y = d < ROT / 2 ? a * c - z * s : a * s + z * c;
}
@@ -103,18 +109,22 @@ __global__ void append(const float* __restrict__ raw, const int32_t* __restrict_
// the RMS norm, gamma, the rotation at pos_base + 4b. `k(j)` is the key of the block's cell j: a row of this batch
// (cell >= p0) or the tail the previous batch left (cell < p0). Only the batch's last completed block writes the
// spare after it (every earlier one's is overwritten by the next block in order).
template <bool TAB>
__device__ __forceinline__ float pooled_value(const float* values, int d, int rope_pos, float theta_scale,
float freq_scale, float corr_low, float corr_high,
float ext_factor, float mscale, const int32_t* mtab, bool zero_pos) {
float ext_factor, float mscale, const int32_t* mtab, bool zero_pos,
const RopeTab& rt) {
float y = values[d];
if (d < ROT) {
const int pair = d % (ROT / 2);
// The same rotation the single append applies, rope_scaling.hpp's helper shared: under none this is
// today's cosf/sinf; under linear/YaRN the pooled keys move with the cache's K, and the spare's zero
// angle carries the mscale magnitude through cos(0) exactly as the single append's does.
const float theta_extrap = (zero_pos ? 0 : mrope_pos(mtab, rope_pos, pair)) * powf(theta_scale, float(pair));
float c, s;
rope_scaled_angle(theta_extrap, freq_scale, corr_low, corr_high, ext_factor, mscale, pair, c, s);
if (!(TAB && rope_tab_cs(rt, zero_pos ? 0 : mrope_pos(mtab, rope_pos, pair), pair, c, s))) {
const float theta_extrap = (zero_pos ? 0 : mrope_pos(mtab, rope_pos, pair)) * powf(theta_scale, float(pair));
rope_scaled_angle(theta_extrap, freq_scale, corr_low, corr_high, ext_factor, mscale, pair, c, s);
}
const float a = values[pair], z = values[pair + ROT / 2];
y = d < ROT / 2 ? a * c - z * s : a * s + z * c;
}
@@ -131,10 +141,11 @@ __device__ __forceinline__ float norm_scale(float mean, float* partials, int d)
return square_sum;
}
// cell 0 of a sequence: the spare (every gather index names cell 0), written to pooled[0] and dead
template <bool TAB>
__global__ void append_first(const float* __restrict__ raw, const float* __restrict__ gamma, float epsilon,
float* __restrict__ dead, float* __restrict__ pooled, float theta_scale,
float freq_scale, float corr_low, float corr_high, float ext_factor, float mscale,
const int32_t* __restrict__ mtab) {
const int32_t* __restrict__ mtab, RopeTab rt) {
const int d = threadIdx.x;
__shared__ float values[D];
__shared__ float partials[32];
@@ -151,17 +162,18 @@ __global__ void append_first(const float* __restrict__ raw, const float* __restr
if (d < D) values[d] = scale * mean * gamma[d];
__syncthreads();
if (d >= D) return;
const float y = pooled_value(values, d, 0, theta_scale, freq_scale, corr_low, corr_high, ext_factor, mscale,
mtab, true);
const float y = pooled_value<TAB>(values, d, 0, theta_scale, freq_scale, corr_low, corr_high, ext_factor, mscale,
mtab, true, rt);
pooled[d] = y;
dead[d] = y;
}
template <bool TAB>
__global__ void append_blocks(const float* __restrict__ raw, int64_t n, int64_t p0, int pos_base,
const float* __restrict__ gamma, float epsilon, const float* __restrict__ tail,
const float* __restrict__ dead, float* __restrict__ pooled, int32_t* __restrict__ block_pos,
int64_t first_block, int64_t last_block, float theta_scale, float freq_scale,
float corr_low, float corr_high, float ext_factor, float mscale,
const int32_t* __restrict__ mtab) {
const int32_t* __restrict__ mtab, RopeTab rt) {
const int64_t b = first_block + blockIdx.x;
const int d = threadIdx.x;
__shared__ float values[D];
@@ -183,8 +195,8 @@ __global__ void append_blocks(const float* __restrict__ raw, int64_t n, int64_t
__syncthreads();
if (d >= D) return;
const int rope_pos = pos_base + R * (int) b;
pooled[std::size_t(b) * D + d] = pooled_value(values, d, rope_pos, theta_scale, freq_scale, corr_low, corr_high,
ext_factor, mscale, mtab, false);
pooled[std::size_t(b) * D + d] = pooled_value<TAB>(values, d, rope_pos, theta_scale, freq_scale, corr_low, corr_high,
ext_factor, mscale, mtab, false, rt);
if (b == last_block) {
pooled[std::size_t(b + 1) * D + d] = dead[d];
if (d == 0) *block_pos = rope_pos;
@@ -228,9 +240,15 @@ void native_qsa_indexer_append(const float* raw, const int32_t* relative_pos_dev
if (overlaps(spans[i], spans[j])) throw std::invalid_argument("native QSA indexer buffers overlap");
const float theta_scale = powf((float) scaling.freq_base, -2.0f / ROT);
const RopeKernelArgs k = scaling.kernel_args(ROT); // none: the identity constants
append<<<1,THREADS,0,static_cast<cudaStream_t>(stream)>>>(raw,relative_pos_device,pos_base,gamma,epsilon,
b.tail,b.dead,b.pooled,b.block_pos,int(max_cells),theta_scale,k.freq_scale,
k.corr_low,k.corr_high,k.ext_factor,k.attn_factor,mrope_table());
const RopeTab rt = rope_table_for(scaling);
if (rt.cos != nullptr)
append<true><<<1,THREADS,0,static_cast<cudaStream_t>(stream)>>>(raw,relative_pos_device,pos_base,gamma,epsilon,
b.tail,b.dead,b.pooled,b.block_pos,int(max_cells),theta_scale,k.freq_scale,
k.corr_low,k.corr_high,k.ext_factor,k.attn_factor,mrope_table(),rt);
else
append<false><<<1,THREADS,0,static_cast<cudaStream_t>(stream)>>>(raw,relative_pos_device,pos_base,gamma,epsilon,
b.tail,b.dead,b.pooled,b.block_pos,int(max_cells),theta_scale,k.freq_scale,
k.corr_low,k.corr_high,k.ext_factor,k.attn_factor,mrope_table(),rt);
const auto error = cudaGetLastError();
if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error));
}
@@ -247,15 +265,24 @@ void native_qsa_indexer_append_batch(const float* raw, int64_t n, int64_t p0, in
const float fsf = k.freq_scale, cl = k.corr_low, ch = k.corr_high, ef = k.ext_factor, ms = k.attn_factor;
const cudaStream_t st = static_cast<cudaStream_t>(stream);
const int32_t* mtab = mrope_table();
if (p0 == 0) append_first<<<1, THREADS, 0, st>>>(raw, gamma, epsilon, b.dead, b.pooled, theta_scale,
fsf, cl, ch, ef, ms, mtab);
const RopeTab rt = rope_table_for(scaling);
if (p0 == 0) {
if (rt.cos != nullptr)
append_first<true><<<1, THREADS, 0, st>>>(raw, gamma, epsilon, b.dead, b.pooled, theta_scale,
fsf, cl, ch, ef, ms, mtab, rt);
else
append_first<false><<<1, THREADS, 0, st>>>(raw, gamma, epsilon, b.dead, b.pooled, theta_scale,
fsf, cl, ch, ef, ms, mtab, rt);
}
// completed blocks: those whose last cell (4b+3) lies in [p0, p0 + n)
const int64_t first = p0 <= R - 1 ? 0 : (p0 - (R - 1) + R - 1) / R; // the smallest b with 4b+3 >= p0
const int64_t hi = p0 + n - 1 >= R - 1 ? (p0 + n - 1 - (R - 1)) / R : -1; // the largest b with 4b+3 <= p0+n-1
if (hi >= first)
append_blocks<<<(unsigned) (hi - first + 1), THREADS, 0, st>>>(raw, n, p0, pos_base, gamma, epsilon, b.tail,
b.dead, b.pooled, b.block_pos, first, hi,
theta_scale, fsf, cl, ch, ef, ms, mtab);
if (hi >= first && rt.cos != nullptr)
append_blocks<true><<<(unsigned) (hi - first + 1), THREADS, 0, st>>>(raw, n, p0, pos_base, gamma, epsilon,
b.tail, b.dead, b.pooled, b.block_pos, first, hi, theta_scale, fsf, cl, ch, ef, ms, mtab, rt);
else if (hi >= first)
append_blocks<false><<<(unsigned) (hi - first + 1), THREADS, 0, st>>>(raw, n, p0, pos_base, gamma, epsilon,
b.tail, b.dead, b.pooled, b.block_pos, first, hi, theta_scale, fsf, cl, ch, ef, ms, mtab, rt);
append_tail<<<R - 1, D, 0, st>>>(raw, n, p0, b.tail);
const auto error = cudaGetLastError();
if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error));
+52 -7
View File
@@ -28,6 +28,7 @@
#include <atomic>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <limits>
#include <stdexcept>
@@ -38,9 +39,13 @@ bool overlaps(const void* a, size_t an, const void* b, size_t bn) {
auto x = reinterpret_cast<uintptr_t>(a), y = reinterpret_cast<uintptr_t>(b);
return x <= y ? y - x < an : x - y < bn;
}
// TAB (#280, STRATA_ROPE_TABLE=1): the angles from the session's float64 table. The host launches <false> whenever
// no table applies - the default - so the default kernel is 0.1.31's code exactly (the table read is not in it;
// with it merely skipped at run time, the compiled default path changed its results).
template <bool TAB>
__global__ void apply(const float* x, float* out, int rows, int width,
int n_rot, float theta_scale, float freq_scale, float corr_low, float corr_high,
float ext_factor, float mscale, const int* positions, const int32_t* mtab) {
float ext_factor, float mscale, const int* positions, const int32_t* mtab, RopeTab rt) {
const int row = blockIdx.y;
const int pair = blockIdx.x * blockDim.x + threadIdx.x;
if (row >= rows || pair >= width / 2) return;
@@ -52,9 +57,11 @@ __global__ void apply(const float* x, float* out, int rows, int width,
}
return;
}
const float theta_extrap = mrope_pos(mtab, positions[row], pair) * powf(theta_scale, float(pair));
float c, s;
rope_scaled_angle(theta_extrap, freq_scale, corr_low, corr_high, ext_factor, mscale, pair, c, s);
if (!(TAB && rope_tab_cs(rt, mrope_pos(mtab, positions[row], pair), pair, c, s))) {
const float theta_extrap = mrope_pos(mtab, positions[row], pair) * powf(theta_scale, float(pair));
rope_scaled_angle(theta_extrap, freq_scale, corr_low, corr_high, ext_factor, mscale, pair, c, s);
}
const float a = x[start + pair], b = x[start + pair + n_rot / 2];
out[start + pair] = a * c - b * s;
out[start + pair + n_rot / 2] = a * s + b * c;
@@ -72,6 +79,40 @@ int mrope_dev() {
} // namespace
void mrope_table_set(const int32_t* device_table) { mrope_tab[mrope_dev()].store(device_table, std::memory_order_relaxed); }
const int32_t* mrope_table() { return mrope_tab[mrope_dev()].load(std::memory_order_relaxed); }
namespace {
// #280: the float64 angle table of each device, set at session init (before any graph is captured)
struct RopeReg {
RopeTab tab;
RopeScaling scaling;
};
RopeReg rope_tab[kMropeDevices] = {};
bool same_scaling(const RopeScaling& a, const RopeScaling& b) {
return a.type == b.type && a.freq_base == b.freq_base && a.factor == b.factor && a.freq_scale_in == b.freq_scale_in &&
a.orig_ctx == b.orig_ctx && a.ext_factor == b.ext_factor && a.attn_factor == b.attn_factor &&
a.beta_fast == b.beta_fast && a.beta_slow == b.beta_slow;
}
// opt-in: STRATA_ROPE_TABLE=1 (the table's angles differ from the fast-math ones in the last bits, so outputs move)
bool rope_table_enabled() {
static const bool on = [] {
const char* e = std::getenv("STRATA_ROPE_TABLE");
return e != nullptr && e[0] == '1';
}();
return on;
}
} // namespace
void rope_table_set(const float* cos_tab, const float* sin_tab, int max_pos, const RopeScaling& scaling) {
rope_tab[mrope_dev()] = RopeReg{RopeTab{cos_tab, sin_tab, max_pos}, scaling};
}
void rope_table_release(const float* cos_tab) {
// every device's entry that points at it (the caller may free it from another current device)
for (RopeReg& r : rope_tab)
if (cos_tab != nullptr && r.tab.cos == cos_tab) r = RopeReg{};
}
RopeTab rope_table_for(const RopeScaling& scaling) {
if (!rope_table_enabled()) return {};
const RopeReg& r = rope_tab[mrope_dev()];
return r.tab.cos != nullptr && same_scaling(r.scaling, scaling) ? r.tab : RopeTab{};
}
void native_rope_set_enabled(bool value) { enabled.store(value, std::memory_order_relaxed); }
bool native_rope_enabled() { return enabled.load(std::memory_order_relaxed); }
void native_rope_apply(const float* x, float* out, int rows, int head_dim,
@@ -92,10 +133,14 @@ void native_rope_apply(const float* x, float* out, int rows, int head_dim,
// Match pinned host-side float powf before device fast powf/trigonometry.
const float theta_scale = powf((float) scaling.freq_base, -2.0f / n_rot);
const RopeKernelArgs k = scaling.kernel_args(n_rot); // none: the identity constants
apply<<<dim3((head_dim / 2 + 127) / 128, rows), 128, 0,
static_cast<cudaStream_t>(stream)>>>(x, out, rows, head_dim, n_rot, theta_scale,
k.freq_scale, k.corr_low, k.corr_high, k.ext_factor,
k.attn_factor, positions, mrope_table());
const RopeTab rt = rope_table_for(scaling);
const dim3 grid((head_dim / 2 + 127) / 128, rows);
if (rt.cos != nullptr)
apply<true><<<grid, 128, 0, static_cast<cudaStream_t>(stream)>>>(x, out, rows, head_dim, n_rot, theta_scale,
k.freq_scale, k.corr_low, k.corr_high, k.ext_factor, k.attn_factor, positions, mrope_table(), rt);
else
apply<false><<<grid, 128, 0, static_cast<cudaStream_t>(stream)>>>(x, out, rows, head_dim, n_rot, theta_scale,
k.freq_scale, k.corr_low, k.corr_high, k.ext_factor, k.attn_factor, positions, mrope_table(), rt);
const auto error = cudaGetLastError();
if (error != cudaSuccess) throw std::runtime_error(cudaGetErrorString(error));
}
+2
View File
@@ -40,6 +40,7 @@
// Every comparison still passed at any sane tolerance, the spare key landing 0.4 f32 ulp away; only a
// BIT-EXACT assertion plus a full-precision probe of the intermediate found it. That is the strongest
// argument in this file for keeping both.
#include "strata/core/emulate.hpp"
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/f16_bits.hpp"
@@ -673,6 +674,7 @@ void qsa_attend_step(const float* q, const uint16_t* k_scratch, const uint16_t*
int dev = 0, max_shared = 0;
cudaGetDevice(&dev);
cudaDeviceGetAttribute(&max_shared, cudaDevAttrMaxSharedMemoryPerBlockOptin, dev);
max_shared = strata::smem_optin_of(max_shared);
if ((int) smem > max_shared) {
std::fprintf(stderr, "qsa: qsa_attend: max_ids %lld needs %zu B of shared, over the %d B limit\n",
(long long) max_ids, smem, max_shared);
+305 -8
View File
@@ -1,4 +1,7 @@
// src/kernels/cuda/qsa_prompt_attn.cu - see include/strata/kernels/qsa_prompt_attn.hpp.
#include "strata/core/emulate.hpp"
#include <cstdlib>
#include <cstring>
#include "strata/kernels/qsa_prompt_attn.hpp"
#include "strata/kernels/kv_q8.hpp"
#include "strata/kernels/kv_q4.hpp"
@@ -9,6 +12,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <type_traits>
namespace strata::kernels {
@@ -685,6 +689,287 @@ bool launch(const float* q, const QsaAttnPools& pools, const int32_t* ids, const
return true;
}
#if defined(__HIPCC__)
// ---- S6: the int8-KV prompt attention on RDNA4 matrix cores (opt-in: STRATA_HIP_WMMA=1, gfx12 only). The design of
// the v2 kernel above with gfx12's v_wmma_f32_16x16x16_f16 (wave32) in place of m16n8k16: wave w owns dims
// [64w, 64w+64) (int8 scale group w) for q.k and p.v; q and p are split into FP16 hi + lo parts, the int8 codes enter
// exactly as FP16, the scales are applied in FP32 and the four groups' q.k partials are added in a fixed order.
// FP32-level accuracy, deterministic, but not bitwise equal to qsa_decode_attn_batch (another summation order).
// Fragment layout (16x16x16, wave32, checked on gfx1201): A lane l holds A[l % 16][(l / 16) * 8 + i], B lane l holds
// B[(l / 16) * 8 + i][l % 16], C/D lane l holds D[(l / 16) * 8 + i][l % 16], i = 0..7.
#if defined(__gfx1200__) || defined(__gfx1201__)
#define STRATA_PA_WMMA 1
#else
#define STRATA_PA_WMMA 0
#endif
typedef _Float16 wh8 __attribute__((ext_vector_type(8)));
typedef float wf8 __attribute__((ext_vector_type(8)));
constexpr int WCH = 32; // cells per chunk (two 16-cell tiles)
constexpr int WVS = 80; // staged V row stride in bytes (64 codes, 16-byte aligned, banks spread)
struct alignas(16) SmemW {
float part[4][16][WCH + 1]; // q.k per dim group
float p[16][WCH + 1];
float vs[4][WCH]; // V scale per (group, cell)
int valid[WCH]; // the cell has a resident pool row
uint8_t v[4][WCH][WVS]; // per wave: the chunk's V codes of its group, one row per cell
float qmax[4];
float alpha[16];
float lsum[16];
float mrow[16];
};
__device__ __forceinline__ wf8 wmma_f16(wh8 a, wh8 b, wf8 c) {
#if STRATA_PA_WMMA
return __builtin_amdgcn_wmma_f32_16x16x16_f16_w32_gfx12(a, b, c);
#else
__builtin_trap();
return c;
#endif
}
__device__ __forceinline__ wh8 i8x8_to_h8(uint2 x) { // exact: |code| <= 128
wh8 h;
#pragma unroll
for (int i = 0; i < 4; ++i) {
h[i] = (_Float16) (int) (int8_t) (x.x >> (8 * i));
h[4 + i] = (_Float16) (int) (int8_t) (x.y >> (8 * i));
}
return h;
}
__global__ void __launch_bounds__(THREADS) prompt_attn_wmma_kernel(const float* __restrict__ q, QsaAttnPools p,
const int32_t* __restrict__ ids,
const int32_t* __restrict__ steps, int n_kv_heads,
int page_size, float scale_log2,
float* __restrict__ attn, int cap) {
#if STRATA_PA_WMMA
__shared__ SmemW S;
const int qi = blockIdx.x, kvh = blockIdx.y;
const int n_head = n_kv_heads * G;
q += (size_t) qi * n_head * HD + (size_t) kvh * G * HD;
attn += (size_t) qi * n_head * HD + (size_t) kvh * G * HD;
ids += (size_t) qi * cap;
const int n = __ldg(steps + (size_t) qi * kStepCount + kStepWidth);
const int t = threadIdx.x, lane = t & 31, warp = t >> 5;
const int col = lane & 15, half = lane >> 4; // fragment column / k half
const int dim0 = warp * 64;
float qm = 0.0f;
for (int i = t; i < G * HD; i += THREADS) qm = fmaxf(qm, fabsf(q[i]));
#pragma unroll
for (int o = 16; o > 0; o >>= 1) qm = fmaxf(qm, __shfl_xor_sync(0xffffffffu, qm, o));
if (lane == 0) S.qmax[warp] = qm;
if (t < 16) { S.mrow[t] = -INFINITY; S.lsum[t] = 0.0f; }
__syncthreads();
qm = fmaxf(fmaxf(S.qmax[0], S.qmax[1]), fmaxf(S.qmax[2], S.qmax[3]));
int qe = 0;
if (qm > 0.0f) frexpf(qm, &qe);
const float qup = ldexpf(1.0f, 14 - qe), qdown = ldexpf(scale_log2, qe - 14);
// q A fragments of this wave's 64 dims: row = col (heads 12..15 zero), k = dim0 + kk*16 + half*8 + i
wh8 qh[4], ql[4];
#pragma unroll
for (int kk = 0; kk < 4; ++kk) {
float x[8];
if (col < G) {
const float4* src = reinterpret_cast<const float4*>(q + (size_t) col * HD + dim0 + kk * 16 + half * 8);
const float4 a = src[0], b = src[1];
x[0] = a.x; x[1] = a.y; x[2] = a.z; x[3] = a.w; x[4] = b.x; x[5] = b.y; x[6] = b.z; x[7] = b.w;
} else {
#pragma unroll
for (int i = 0; i < 8; ++i) x[i] = 0.0f;
}
#pragma unroll
for (int i = 0; i < 8; ++i) {
const float v = x[i] * qup;
const _Float16 hi = (_Float16) v;
qh[kk][i] = hi;
ql[kk][i] = (_Float16) (v - (float) hi);
}
}
wf8 acc[4];
#pragma unroll
for (int j = 0; j < 4; ++j) acc[j] = wf8{0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
const int n_chunks = (n + WCH - 1) / WCH;
for (int ci = 0; ci < n_chunks; ++ci) {
const int c0 = ci * WCH;
// this lane's cell of the chunk (lane = cell index): its pool row, or -1 (past the selection, or a block the
// KV streaming left non-resident: masked as in the decode kernel)
long long myrow = -1;
if (c0 + lane < n) {
const int cell = __ldg(ids + c0 + lane);
const long long page = (long long) __ldg(p.page_table + cell / page_size);
if (page >= 0) myrow = (page * n_kv_heads + kvh) * page_size + (cell % page_size);
}
// stage this wave's V slice of the chunk (64 bytes of each row) and the V scale; the K fragments come straight
// from global memory (8 consecutive codes per lane)
{
uint4 v0 = make_uint4(0, 0, 0, 0), v1 = v0, v2 = v0, v3 = v0;
float vsc = 0.0f;
if (myrow >= 0) {
const uint4* src = reinterpret_cast<const uint4*>(p.v_q + myrow * HD + dim0);
v0 = __ldg(src); v1 = __ldg(src + 1); v2 = __ldg(src + 2); v3 = __ldg(src + 3);
vsc = __half2float(__ushort_as_half(__ldg(p.v_scale + myrow * (HD / KV_Q8_GROUP) + warp)));
}
uint4* dst = reinterpret_cast<uint4*>(&S.v[warp][lane][0]);
dst[0] = v0; dst[1] = v1; dst[2] = v2; dst[3] = v3;
S.vs[warp][lane] = vsc;
if (warp == 0) S.valid[lane] = myrow >= 0;
}
// q.k over this wave's 64 dims for two 16-cell tiles, times the cell's K scale for this group
#pragma unroll
for (int nt = 0; nt < 2; ++nt) {
const long long rr = __shfl(myrow, nt * 16 + col);
wf8 s = wf8{0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
float ksc = 0.0f;
if (rr >= 0) ksc = __half2float(__ushort_as_half(__ldg(p.k_scale + rr * (HD / KV_Q8_GROUP) + warp)));
#pragma unroll
for (int kk = 0; kk < 4; ++kk) {
uint2 kx = make_uint2(0, 0);
if (rr >= 0) kx = __ldg(reinterpret_cast<const uint2*>(p.k_q + rr * HD + dim0 + kk * 16 + half * 8));
const wh8 b = i8x8_to_h8(kx);
s = wmma_f16(qh[kk], b, s);
s = wmma_f16(ql[kk], b, s);
}
#pragma unroll
for (int i = 0; i < 8; ++i) S.part[warp][half * 8 + i][nt * 16 + col] = s[i] * ksc;
}
__syncthreads();
// online softmax over the four groups' sum (fixed order): row t/8, 4 cells per thread
{
const int r = t >> 3, sub = t & 7;
float x[4], mx = -INFINITY;
#pragma unroll
for (int j = 0; j < 4; ++j) {
const int c = sub * 4 + j;
// past the selection, or a masked cell (non-resident page, as in the decode kernel): no weight
x[j] = S.valid[c] ? (((S.part[0][r][c] + S.part[1][r][c]) + S.part[2][r][c]) + S.part[3][r][c]) * qdown
: -INFINITY;
mx = fmaxf(mx, x[j]);
}
#pragma unroll
for (int o = 1; o < 8; o <<= 1) mx = fmaxf(mx, __shfl_xor_sync(0xffffffffu, mx, o));
const float m_old = S.mrow[r];
const float m_new = fmaxf(m_old, mx);
float sum = 0.0f;
#pragma unroll
for (int j = 0; j < 4; ++j) {
const float e = x[j] == -INFINITY ? 0.0f : exp2f(x[j] - m_new);
S.p[r][sub * 4 + j] = e;
sum += e;
}
#pragma unroll
for (int o = 1; o < 8; o <<= 1) sum += __shfl_xor_sync(0xffffffffu, sum, o);
if (sub == 0) {
const float a = m_old == -INFINITY ? 0.0f : exp2f(m_old - m_new);
S.alpha[r] = a;
S.lsum[r] = fmaf(S.lsum[r], a, sum);
S.mrow[r] = m_new;
}
}
__syncthreads();
// p.v over this wave's 64 dims: 4 tiles of 16 dims, 2 k-steps of 16 cells
{
float vmax = S.vs[warp][lane];
#pragma unroll
for (int o = 16; o > 0; o >>= 1) vmax = fmaxf(vmax, __shfl_xor_sync(0xffffffffu, vmax, o));
const float vup = vmax > 0.0f ? 16384.0f / vmax : 0.0f, vdown = vmax * (1.0f / 16384.0f);
wf8 tmp[4];
#pragma unroll
for (int j = 0; j < 4; ++j) tmp[j] = wf8{0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
#pragma unroll
for (int ks = 0; ks < 2; ++ks) {
const int cb = ks * 16 + half * 8; // this lane's 8 cells
wh8 ah, al;
#pragma unroll
for (int i = 0; i < 8; ++i) {
const float pv = S.p[col][cb + i] * (S.vs[warp][cb + i] * vup);
const _Float16 hi = (_Float16) pv;
ah[i] = hi;
al[i] = (_Float16) (pv - (float) hi);
}
#pragma unroll
for (int j = 0; j < 4; ++j) {
const int d = j * 16 + col;
wh8 b;
#pragma unroll
for (int i = 0; i < 8; ++i) b[i] = (_Float16) (int) (int8_t) S.v[warp][cb + i][d];
tmp[j] = wmma_f16(ah, b, tmp[j]);
tmp[j] = wmma_f16(al, b, tmp[j]);
}
}
float a[8];
#pragma unroll
for (int i = 0; i < 8; ++i) a[i] = S.alpha[half * 8 + i];
#pragma unroll
for (int j = 0; j < 4; ++j)
#pragma unroll
for (int i = 0; i < 8; ++i) acc[j][i] = fmaf(acc[j][i], a[i], tmp[j][i] * vdown);
}
// no barrier here: `v`/`vs` are this wave's own, and `p`/`alpha`/`valid` are rewritten only after the next
// chunk's first barrier (which every wave reaches after its p.v)
}
float inv[8];
#pragma unroll
for (int i = 0; i < 8; ++i) {
const float l = S.lsum[half * 8 + i];
inv[i] = l > 0.0f ? 1.0f / l : 0.0f;
}
#pragma unroll
for (int j = 0; j < 4; ++j)
#pragma unroll
for (int i = 0; i < 8; ++i) {
const int row = half * 8 + i;
if (row < G) attn[(size_t) row * HD + dim0 + j * 16 + col] = acc[j][i] * inv[i];
}
#else
__builtin_trap();
#endif
}
// gfx12 (RDNA4) only, and only on request: the output differs from the default kernel's in its last bits
bool hip_wmma_usable() {
static const bool want = [] {
const char* e = std::getenv("STRATA_HIP_WMMA");
return e != nullptr && e[0] == '1';
}();
if (!want) return false;
static int arch[64] = {}; // 0 unknown, 1 gfx12, 2 other
int dev = 0;
if (hipGetDevice(&dev) != hipSuccess || dev < 0 || dev >= 64) { (void) hipGetLastError(); return false; }
if (arch[dev] == 0) {
hipDeviceProp_t prop{};
if (hipGetDeviceProperties(&prop, dev) != hipSuccess) { (void) hipGetLastError(); return false; }
arch[dev] = std::strncmp(prop.gcnArchName, "gfx12", 5) == 0 ? 1 : 2;
static bool told = false;
if (!told) {
told = true;
std::fprintf(stderr, "strata: STRATA_HIP_WMMA: the prompt attention on matrix cores %s (%s)\n",
arch[dev] == 1 ? "on" : "unavailable", prop.gcnArchName);
}
}
return arch[dev] == 1;
}
bool launch_wmma(const float* q, const QsaAttnPools& pools, const int32_t* ids, const int32_t* steps, int64_t cap,
const QsaShapes& s, float* attn, int64_t n_q, cudaStream_t st) {
const float scale_log2 = 1.4426950408889634f / sqrtf((float) HD);
for (int64_t q0 = 0; q0 < n_q; q0 += 65535) {
const int64_t nb = n_q - q0 < 65535 ? n_q - q0 : 65535;
prompt_attn_wmma_kernel<<<dim3((unsigned) nb, (unsigned) s.n_head_kv), THREADS, 0, st>>>(
q + q0 * s.n_head * HD, pools, ids + q0 * cap, steps + q0 * kStepCount, (int) s.n_head_kv,
(int) s.page_size, scale_log2, attn + q0 * s.n_head * HD, (int) cap);
}
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
std::fprintf(stderr, "qsa_prompt_attn_batch (wmma): %s\n", cudaGetErrorString(e));
std::exit(1);
}
return true;
}
#endif
} // namespace
bool qsa_prompt_attn_batch(const float* q, const QsaAttnPools& pools, const int32_t* ids, const int32_t* steps,
@@ -694,22 +979,34 @@ bool qsa_prompt_attn_batch(const float* q, const QsaAttnPools& pools, const int3
{ // sm_75 or newer: the MMA above compiles for both. sm_80+ runs the cp.async kernel (launch_i8); Turing has
// no cp.async, so it runs the v1 kernel (launch<1>, same accuracy, another summation order). An older card
// keeps the old kernel.
static int cc_major[64] = {};
// #371: the compute capability with its minor - sm_70 (V100) has no m16n8k8 (the kernels trap below sm_75)
static int cc[64] = {};
int dev = 0;
if (cudaGetDevice(&dev) != cudaSuccess || dev < 0 || dev >= 64) { cudaGetLastError(); return false; }
if (cc_major[dev] == 0) {
int major = 0;
if (cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev) != cudaSuccess) {
if (cc[dev] == 0) {
int major = 0, minor = 0;
if (cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev) != cudaSuccess ||
cudaDeviceGetAttribute(&minor, cudaDevAttrComputeCapabilityMinor, dev) != cudaSuccess) {
cudaGetLastError();
return false;
}
cc_major[dev] = major;
// STRATA_QSA_WARP=1|attn (an A/B arm): the pre-sm_80 kernels on any card, as RTX 20 runs them
const char* w = std::getenv("STRATA_QSA_WARP");
cc[dev] = w && (!std::strcmp(w, "1") || !std::strcmp(w, "attn")) ? 75
: 10 * strata::cc_major_of(major) + strata::cc_minor_of(minor);
}
if (cc_major[dev] < 7) return false;
turing = cc_major[dev] < 8;
if (cc[dev] < 75) return false;
turing = cc[dev] < 80;
}
#if defined(__HIPCC__)
return false; // the tensor-core kernel is compiled out on AMD (its major version is not a CUDA sm)
// the tensor-core kernels are compiled out on AMD (its major version is not a CUDA sm); RDNA4 has its own int8-KV
// matrix-core kernel, opt-in (STRATA_HIP_WMMA=1); everything else keeps the old kernel
(void) turing;
if (pools.k_q != nullptr && pools.v_q != nullptr && pools.k_scale != nullptr && pools.v_scale != nullptr &&
pools.k_q4 == nullptr && pools.v_q4 == nullptr && s.head_dim == HD && s.n_head == (int64_t) G * s.n_head_kv &&
cap > 0 && ids && steps && pools.page_table && hip_wmma_usable())
return launch_wmma(q, pools, ids, steps, cap, s, attn, n_q, (cudaStream_t) stream);
return false;
#endif
if (pools.k_q4 != nullptr || s.head_dim != HD || s.n_head != (int64_t) G * s.n_head_kv || cap <= 0 || !ids ||
!steps || !pools.page_table)
+519 -13
View File
@@ -1,4 +1,7 @@
// src/kernels/cuda/qsa_select.cu - see include/strata/kernels/qsa_select.hpp.
#include "strata/core/emulate.hpp"
#include <cstdlib>
#include <cstring>
#include "strata/kernels/qsa_select.hpp"
#include <cuda_runtime.h>
@@ -6,6 +9,7 @@
#include <cfloat>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace strata::kernels {
namespace {
@@ -276,6 +280,160 @@ __global__ void __launch_bounds__(128) block_scores_tc_kernel(const float* __res
}
}
#if defined(__HIPCC__)
// ---- gfx12 (RDNA4: gfx1200 / gfx1201) scorer on v_wmma_f32_16x16x16_bf16 (wave32). Same GEMM as above: for a tile of 16
// queries, every block's score is sum over the 4 indexer heads of relu(q_h . k), K = 128. FP32-level accuracy from a
// three-way bf16 split of both operands (x = hi + mid + lo EXACTLY - the top 8, next 8 and last 8 bits of the fp32
// mantissa, so bf16's fp32-sized exponent range needs no scaling) and the six products of order <= 2, smallest first;
// like the TF32 kernel it sums in another order than the warp kernel (not bitwise). The tail block n_bid is the warp
// kernel's arithmetic (block_scores_tail_kernel).
// D = A x B with A = 16 key blocks (row l%16 of a lane, k = 8*(l/16)+i), B = 16 queries (column l%16, same k):
// a lane ends up with 8 consecutive blocks of ONE query, written as one run. No LDS for keys: a lane reads its
// 32-byte slice of the key row from global (the 16 query tiles of a launch re-read them from L2); the queries are
// split once per CTA into LDS.
#if defined(__gfx1200__) || defined(__gfx1201__)
#define STRATA_SEL_GFX12 1
#else
#define STRATA_SEL_GFX12 0
#endif
typedef short sel_s8 __attribute__((ext_vector_type(8)));
typedef float sel_f8 __attribute__((ext_vector_type(8)));
typedef uint32_t sel_u4 __attribute__((ext_vector_type(4)));
constexpr int WQT = 16; // queries per CTA (the N of one WMMA)
constexpr int WITER = 4; // key tiles per warp (the CTA covers 4 warps * WITER * 16 blocks)
constexpr int WQS = IDX_DIM + 8; // bf16 elements per LDS row: 272 bytes, conflict-free 16-byte reads
__device__ __forceinline__ sel_f8 wmma_bf16(const sel_s8& a, const sel_s8& b, const sel_f8& c) {
#if STRATA_SEL_GFX12
return __builtin_amdgcn_wmma_f32_16x16x16_bf16_w32_gfx12(a, b, c);
#else
__trap();
return c;
#endif
}
// the bf16 pieces of x as fp32 bit patterns whose upper 16 bits are the bf16 (lower 16 are zero)
__device__ __forceinline__ void split3(float x, uint32_t& hi, uint32_t& mid, uint32_t& lo) {
hi = __float_as_uint(x) & 0xffff0000u;
const float r1 = x - __uint_as_float(hi); // exact
mid = __float_as_uint(r1) & 0xffff0000u;
lo = __float_as_uint(r1 - __uint_as_float(mid)) & 0xffff0000u; // exact: at most 8 significant bits left
}
__device__ __forceinline__ uint32_t pack_bf16x2(uint32_t a, uint32_t b) { // low half = a's bf16, high half = b's
#if STRATA_SEL_GFX12
return __builtin_amdgcn_perm(b, a, 0x07060302u);
#else
return (a >> 16) | (b & 0xffff0000u);
#endif
}
__global__ void __launch_bounds__(128) block_scores_wmma_kernel(const float* __restrict__ pooled,
const float* __restrict__ q_idx,
const int32_t* __restrict__ steps, int64_t nq,
int64_t max_blocks, int64_t reach,
float* __restrict__ out) {
__shared__ __align__(16) uint16_t sq[3][IDX_HEADS][WQT][WQS]; // hi / mid / lo, [head][query][dim]
__shared__ int s_nbid[WQT];
const int t = threadIdx.x, lane = t & 31, warp = t >> 5, l16 = lane & 15, g = lane >> 4;
const int64_t q0 = (int64_t) blockIdx.y * WQT;
for (int i = t; i < WQT * IDX_HEADS * IDX_DIM / 4; i += 128) {
const int row = i / (IDX_DIM / 4), c = i % (IDX_DIM / 4);
const int qr = row / IDX_HEADS, h = row % IDX_HEADS;
float4 v = make_float4(0.f, 0.f, 0.f, 0.f);
if (q0 + qr < nq) v = reinterpret_cast<const float4*>(q_idx + (q0 + qr) * IDX_HEADS * IDX_DIM)[h * (IDX_DIM / 4) + c];
const float x[4] = {v.x, v.y, v.z, v.w};
uint32_t hb[4], mb[4], lb[4];
#pragma unroll
for (int j = 0; j < 4; ++j) split3(x[j], hb[j], mb[j], lb[j]);
*reinterpret_cast<uint2*>(&sq[0][h][qr][c * 4]) = make_uint2(pack_bf16x2(hb[0], hb[1]), pack_bf16x2(hb[2], hb[3]));
*reinterpret_cast<uint2*>(&sq[1][h][qr][c * 4]) = make_uint2(pack_bf16x2(mb[0], mb[1]), pack_bf16x2(mb[2], mb[3]));
*reinterpret_cast<uint2*>(&sq[2][h][qr][c * 4]) = make_uint2(pack_bf16x2(lb[0], lb[1]), pack_bf16x2(lb[2], lb[3]));
}
if (t < WQT) s_nbid[t] = q0 + t < nq ? steps[(q0 + t) * kStepCount + kStepNBid] : 0;
__syncthreads();
int hi_nbid = 0;
#pragma unroll
for (int i = 0; i < WQT; ++i) hi_nbid = max(hi_nbid, s_nbid[i]);
const int64_t qi = q0 + l16;
const int nb_q = s_nbid[l16];
for (int it = 0; it < WITER; ++it) {
const int64_t b0 = (((int64_t) blockIdx.x * WITER + it) * 4 + warp) * 16;
if (b0 >= reach || b0 >= hi_nbid) break; // warp-uniform; later tiles start higher
const int64_t row = b0 + l16;
const bool rv = row < hi_nbid && row < max_blocks;
const float* kp = pooled + (rv ? row : 0) * IDX_DIM + g * 8;
// acc: the hi*hi products; cor: the five smaller ones (<= 2^-8 of it). Kept apart and added once at the end: a
// correction summed into the big accumulator is rounded to ITS ulp at every one of the 48 steps (measured: 8e-7
// of the score scale; apart, near the warp kernel's)
sel_f8 acc[IDX_HEADS], cor[IDX_HEADS];
#pragma unroll
for (int h = 0; h < IDX_HEADS; ++h) acc[h] = cor[h] = sel_f8{0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f, 0.f};
#pragma unroll 2
for (int kk = 0; kk < IDX_DIM / 16; ++kk) {
float4 k0 = make_float4(0.f, 0.f, 0.f, 0.f), k1 = k0;
if (rv) {
k0 = *reinterpret_cast<const float4*>(kp + kk * 16);
k1 = *reinterpret_cast<const float4*>(kp + kk * 16 + 4);
}
const float x[8] = {k0.x, k0.y, k0.z, k0.w, k1.x, k1.y, k1.z, k1.w};
uint32_t hb[8], mb[8], lb[8];
#pragma unroll
for (int j = 0; j < 8; ++j) split3(x[j], hb[j], mb[j], lb[j]);
sel_u4 ah, am, al;
#pragma unroll
for (int j = 0; j < 4; ++j) {
ah[j] = pack_bf16x2(hb[2 * j], hb[2 * j + 1]);
am[j] = pack_bf16x2(mb[2 * j], mb[2 * j + 1]);
al[j] = pack_bf16x2(lb[2 * j], lb[2 * j + 1]);
}
const sel_s8 Ah = __builtin_bit_cast(sel_s8, ah), Am = __builtin_bit_cast(sel_s8, am),
Al = __builtin_bit_cast(sel_s8, al);
#pragma unroll
for (int h = 0; h < IDX_HEADS; ++h) {
const sel_s8 Bh = *reinterpret_cast<const sel_s8*>(&sq[0][h][l16][kk * 16 + g * 8]);
const sel_s8 Bm = *reinterpret_cast<const sel_s8*>(&sq[1][h][l16][kk * 16 + g * 8]);
const sel_s8 Bl = *reinterpret_cast<const sel_s8*>(&sq[2][h][l16][kk * 16 + g * 8]);
cor[h] = wmma_bf16(Al, Bh, cor[h]);
cor[h] = wmma_bf16(Ah, Bl, cor[h]);
cor[h] = wmma_bf16(Am, Bm, cor[h]);
cor[h] = wmma_bf16(Ah, Bm, cor[h]);
cor[h] = wmma_bf16(Am, Bh, cor[h]);
acc[h] = wmma_bf16(Ah, Bh, acc[h]);
}
}
// relu per head, heads added in order (as the warp kernel); this lane: query qi, blocks b0 + 8g .. 8g+7
if (qi < nq) {
#pragma unroll
for (int i = 0; i < 8; ++i) {
const int64_t b = b0 + g * 8 + i;
if (b >= nb_q || b >= max_blocks) continue;
float score = 0.0f;
#pragma unroll
for (int h = 0; h < IDX_HEADS; ++h) {
const float d = acc[h][i] + cor[h][i];
score += d > 0.0f ? d : 0.0f;
}
out[qi * max_blocks + b] = score;
}
}
}
}
// the gfx12 kernel needs gfx1200/gfx1201 code objects and a gfx12 device (gfx1100 has WMMA too, with another layout)
bool sel_gfx12_device() {
static int ok[64] = {}; // per device: 0 unknown, 1 yes, 2 no
int dev = 0;
if (cudaGetDevice(&dev) != cudaSuccess || dev < 0 || dev >= 64) { cudaGetLastError(); return false; }
if (ok[dev] == 0) {
cudaDeviceProp prop;
ok[dev] = (cudaGetDeviceProperties(&prop, dev) == cudaSuccess &&
(std::strncmp(prop.gcnArchName, "gfx1200", 7) == 0 || std::strncmp(prop.gcnArchName, "gfx1201", 7) == 0))
? 1 : 2;
cudaGetLastError();
}
return ok[dev] == 1;
}
#endif // __HIPCC__
// the tail block n_bid of each query: exactly block_scores_kernel's arithmetic for that block
__global__ void __launch_bounds__(32) block_scores_tail_kernel(const float* __restrict__ dead,
const float* __restrict__ q_idx,
@@ -308,6 +466,14 @@ __global__ void __launch_bounds__(32) block_scores_tail_kernel(const float* __re
// block_topk_kernel's (radix threshold, ties to the lowest index, cells ascending): identical ids.
constexpr int TK_T = 1024;
constexpr int TK_PER = 33;
#if defined(__HIPCC__)
// AMD (RDNA, wave32): a 1,024-thread block may use 192 VGPRs per lane, so the keys of 66 blocks per thread fit - contexts
// up to 4 * 1024 * 66 = 270,336 cells (the 262,144 --max-context) keep the register kernel. NVIDIA's 64 registers per
// thread at 1,024 threads allow only TK_PER.
constexpr int TK_PER_MAX = 66;
#else
constexpr int TK_PER_MAX = TK_PER;
#endif
__device__ __forceinline__ int block_excl_scan(int v, int* s_warp, int& total) {
const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5;
@@ -337,6 +503,7 @@ __device__ __forceinline__ int block_excl_scan(int v, int* s_warp, int& total) {
return r;
}
template <int PER>
__global__ void __launch_bounds__(TK_T) block_topk_reg_kernel(const float* __restrict__ scores,
const int32_t* __restrict__ steps, int64_t max_blocks,
int64_t cap, int32_t* __restrict__ ids) {
@@ -354,11 +521,11 @@ __global__ void __launch_bounds__(TK_T) block_topk_reg_kernel(const float* __res
}
const float* sc = scores + qi * max_blocks;
const int64_t nb = n_bid + 1;
const int64_t per = (nb + TK_T - 1) / TK_T; // <= TK_PER (the caller checks)
const int64_t per = (nb + TK_T - 1) / TK_T; // <= PER (the caller checks)
const int64_t b0 = (int64_t) t * per, b1 = (b0 + per < nb) ? b0 + per : nb;
uint32_t key[TK_PER];
uint32_t key[PER];
#pragma unroll
for (int j = 0; j < TK_PER; ++j) key[j] = (b0 + j < b1) ? order_key(sc[b0 + j]) : 0u;
for (int j = 0; j < PER; ++j) key[j] = (b0 + j < b1) ? order_key(sc[b0 + j]) : 0u;
auto weight = [&](int64_t b) -> int { return b < n_bid ? R : (int) (n_kv - n_bid * R); };
uint32_t prefix = 0;
int above = 0;
@@ -367,7 +534,7 @@ __global__ void __launch_bounds__(TK_T) block_topk_reg_kernel(const float* __res
__syncwarp();
const uint32_t hi_mask = shift == 24 ? 0u : (0xffffffffu << (shift + 8));
#pragma unroll
for (int j = 0; j < TK_PER; ++j) {
for (int j = 0; j < PER; ++j) {
const int64_t b = b0 + j;
if (b >= b1) break;
const int w = weight(b);
@@ -399,7 +566,7 @@ __global__ void __launch_bounds__(TK_T) block_topk_reg_kernel(const float* __res
const int64_t eq_budget = width - above;
int gt = 0, eq = 0;
#pragma unroll
for (int j = 0; j < TK_PER; ++j) {
for (int j = 0; j < PER; ++j) {
const int64_t b = b0 + j;
if (b >= b1) break;
const int w = weight(b);
@@ -416,7 +583,7 @@ __global__ void __launch_bounds__(TK_T) block_topk_reg_kernel(const float* __res
int64_t wpos = block_excl_scan(sel, s_warp, tot);
int64_t eq_left = my_eq;
#pragma unroll
for (int j = 0; j < TK_PER; ++j) {
for (int j = 0; j < PER; ++j) {
const int64_t b = b0 + j;
if (b >= b1) break;
const int w = weight(b);
@@ -477,6 +644,229 @@ __global__ void __launch_bounds__(SCORE_WARPS * 32) block_scores_multi_kernel(co
}
}
}
#if !defined(__HIPCC__)
// ---- the decode top-k on a thread-block cluster (sm_90+; S19). One CTA per query (block_topk_reg_kernel, or
// block_topk_kernel above 33,792 blocks: a --max-context over ~135K) makes four radix passes and two scans over up to
// 65,538 blocks on ONE SM while the rest of the GPU idles - a decode window has 1-5 queries. Here a cluster of CL_N
// CTAs shares a query: CTA r holds the keys of blocks [r * per, (r + 1) * per) in shared memory, builds the digit
// histogram of its keys, and PUSHES it into slot r of every CTA's `hin` (distributed shared memory); after one cluster
// barrier each CTA sums the CL_N slots itself, so all of them take the same digit. The emit pass needs each CTA's
// cells above / at the threshold: pushed the same way, then each CTA offsets its own by the ranks before it.
// RTX 5070, per call at capacity = context (decode_cluster_parity --bench): 21.9 -> 15.6 us at 32K, 58 -> 18 at 128K,
// 200 -> 22 at 262K (the one-CTA kernels' digit search was serial, too: one thread over 256 bins per pass).
//
// IDENTICAL IDS, by construction rather than by luck: the threshold thr (the width-th largest key, cells counted with
// their weights) and `above` (cells with a larger key) are pure functions of the multiset of (key, weight) - integer
// histograms, summed in any order - and the digit rule is block_topk_kernel's, written as a scan (the largest digit d
// whose cells at or above it reach `need`; 0 when none). The cells emitted are then fixed: every cell of a block with
// key > thr, and the first eq_budget = width - above cells at thr in ascending order (a block at thr may be cut),
// written in ascending cell order. Position of a thread's first cell = (cells above thr before it) + min(cells at thr
// before it, eq_budget): the telescoped sum of the reference's per-thread clamp. NaN keys are 0 (order_key) as there.
//
// Barriers: barrier.cluster arrive / wait (each CTA's threads all take part; it also orders the CTA's own shared
// memory, so it doubles as __syncthreads). Phase 0 is a relaxed arrive at entry, waited before the first remote store
// (a CTA's shared memory exists once it runs). Pushes go out before the arrive that releases them, and nothing reads
// another CTA's memory after the last wait - so a CTA may exit without a closing barrier (a wait counts the threads
// that have not exited). `hin` alternates by pass parity: CTA x reads slot buffer p&1 in pass p before arriving at
// pass p+1's barrier, and nobody writes buffer p&1 again (pass p+2) before waiting on that barrier.
constexpr int CL_N = 8; // CTAs per query (the portable cluster size)
constexpr int CL_T = 1024; // threads per CTA
constexpr int CL_MAXQ = 16; // larger calls (prefill sub-batches) fill the GPU with one CTA per query already
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
#define STRATA_SEL_CLUSTER 1
#else
#define STRATA_SEL_CLUSTER 0 // an older target's code is a trap; qsa_block_topk_cluster never launches it there
#endif
#if STRATA_SEL_CLUSTER
__device__ __forceinline__ void cl_arrive_relaxed() {
asm volatile("barrier.cluster.arrive.relaxed.aligned;\n" ::: "memory");
}
__device__ __forceinline__ void cl_arrive() { asm volatile("barrier.cluster.arrive.release.aligned;\n" ::: "memory"); }
__device__ __forceinline__ void cl_wait() { asm volatile("barrier.cluster.wait.acquire.aligned;\n" ::: "memory"); }
__device__ __forceinline__ unsigned cl_rank() {
unsigned r;
asm volatile("mov.u32 %0, %%cluster_ctarank;\n" : "=r"(r));
return r;
}
template <typename T> __device__ __forceinline__ T* cl_map(T* p, unsigned rank) { // p in CTA `rank`'s shared memory
uint64_t o;
asm volatile("mapa.u64 %0, %1, %2;\n" : "=l"(o) : "l"((uint64_t) p), "r"(rank));
return reinterpret_cast<T*>(o);
}
// exclusive block scan of a 64-bit value over CL_T threads; `total` = the CTA's sum
__device__ __forceinline__ unsigned long long cl_excl_scan64(unsigned long long v, unsigned long long* s_warp,
unsigned long long& total) {
const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5;
unsigned long long x = v;
#pragma unroll
for (int o = 1; o < 32; o <<= 1) {
const unsigned long long y = __shfl_up_sync(0xffffffffu, x, o);
if (lane >= o) x += y;
}
if (lane == 31) s_warp[warp] = x;
__syncthreads();
if (warp == 0) {
const unsigned long long w = s_warp[lane];
unsigned long long z = w;
#pragma unroll
for (int o = 1; o < 32; o <<= 1) {
const unsigned long long y = __shfl_up_sync(0xffffffffu, z, o);
if (lane >= o) z += y;
}
s_warp[lane] = z - w;
if (lane == 31) s_warp[32] = z;
}
__syncthreads();
total = s_warp[32];
return s_warp[warp] + x - v;
}
#endif
// grid (CL_N, nq), cluster (CL_N, 1, 1), CL_T threads, dynamic shared memory: ceil(max_blocks / CL_N) keys
__global__ void __launch_bounds__(CL_T) block_topk_cluster_kernel(const float* __restrict__ scores,
const int32_t* __restrict__ steps, int64_t max_blocks,
int64_t cap, int32_t* __restrict__ ids) {
#if STRATA_SEL_CLUSTER
extern __shared__ uint32_t keys[]; // this CTA's blocks' keys
__shared__ __align__(16) int hin[2][CL_N][256]; // the cluster's histograms, slot = the pushing rank
__shared__ __align__(16) int hloc[2][256]; // this CTA's histogram
__shared__ unsigned long long cnt[CL_N]; // per rank: its cells above thr (low 32 bits) and at it (high)
__shared__ unsigned long long s_warp[33];
__shared__ uint32_t s_prefix;
__shared__ int s_above;
const unsigned rank = cl_rank();
const int t = threadIdx.x, lane = t & 31, warp = t >> 5;
cl_arrive_relaxed(); // phase 0: this CTA runs
const int64_t qi = blockIdx.y;
const int32_t* st = steps + qi * kStepCount;
const int64_t n_kv = st[kStepNKv], n_bid = st[kStepNBid], width = st[kStepWidth];
int32_t* out = ids + qi * cap;
if (n_kv <= width) { // the identity, split over the cluster; no remote access
for (int64_t j = (int64_t) rank * CL_T + t; j < n_kv; j += (int64_t) CL_N * CL_T) out[j] = (int32_t) j;
return;
}
// the tail block n_bid weighs its cells (0..3); with none it is not a candidate (the reference skips w == 0)
const int wtail = (int) (n_kv - n_bid * R);
const int64_t nbe = n_bid + (wtail > 0 ? 1 : 0);
const int64_t per = (nbe + CL_N - 1) / CL_N; // <= ceil(max_blocks / CL_N), the keys the host sized
const int64_t lo = (int64_t) rank * per < nbe ? (int64_t) rank * per : nbe;
const int64_t hi = lo + per < nbe ? lo + per : nbe;
const int n = (int) (hi - lo);
const int tail_i = (wtail > 0 && n_bid >= lo && n_bid < hi) ? (int) (n_bid - lo) : -1;
const float* sc = scores + qi * max_blocks + lo;
for (int i = t; i < n; i += CL_T) keys[i] = order_key(sc[i]);
for (int i = t; i < 2 * 256; i += CL_T) (&hloc[0][0])[i] = 0;
__syncthreads();
uint32_t prefix = 0;
int above = 0; // cells strictly above the digits fixed so far
for (int pass = 0; pass < 4; ++pass) {
const int shift = 24 - 8 * pass;
const uint32_t hmask = pass == 0 ? 0u : (0xffffffffu << (shift + 8));
int* hl = hloc[pass & 1];
// warp-aggregated: the scores share their top bits, so plain atomics would queue on a few bins
for (int i0 = 0; i0 < n; i0 += CL_T) {
const int i = i0 + t;
int bin = -1;
if (i < n) {
const uint32_t k = keys[i];
if ((k & hmask) == prefix) bin = (int) ((k >> shift) & 255);
}
const unsigned same = __match_any_sync(0xffffffffu, bin);
if (bin >= 0 && lane == __ffs(same) - 1) atomicAdd(&hl[bin], __popc(same) * R);
if (bin >= 0 && i == tail_i) atomicAdd(&hl[bin], wtail - R);
}
__syncthreads();
if (pass == 0) cl_wait(); // phase 0 done: every CTA's shared memory is there
for (int i = t; i < CL_N * 64; i += CL_T) { // the histogram into slot `rank` of every CTA, as int4
const int dst = i >> 6, c = i & 63;
*cl_map(reinterpret_cast<int4*>(&hin[pass & 1][rank][0]) + c, (unsigned) dst) =
reinterpret_cast<const int4*>(hl)[c];
}
for (int i = t; i < 256; i += CL_T) hloc[(pass + 1) & 1][i] = 0;
cl_arrive();
cl_wait();
if (warp == 0) {
// lane L owns digits 255 - 8L down to 248 - 8L; the digit holding the need-th cell from the top
const int need = (int) width - above;
int tot[8], part = 0;
#pragma unroll
for (int k = 0; k < 8; ++k) {
const int d = 255 - 8 * lane - k;
int v = 0;
#pragma unroll
for (int r = 0; r < CL_N; ++r) v += hin[pass & 1][r][d];
tot[k] = v;
part += v;
}
int incl = part;
#pragma unroll
for (int o = 1; o < 32; o <<= 1) {
const int y = __shfl_up_sync(0xffffffffu, incl, o);
if (lane >= o) incl += y;
}
const int excl = incl - part;
const unsigned hit = __ballot_sync(0xffffffffu, excl < need && incl >= need);
// no hit cannot happen (the cells total n_kv > width); the reference would then take digit 0
const int who = hit ? __ffs(hit) - 1 : 31;
if (lane == who) {
int acc = excl, k = 0;
for (; k < 7; ++k) {
if (acc + tot[k] >= need) break;
acc += tot[k];
}
s_prefix = prefix | ((uint32_t) (255 - 8 * lane - k) << shift);
s_above = above + acc;
}
}
__syncthreads();
prefix = s_prefix;
above = s_above;
}
const uint32_t thr = prefix;
const int64_t eq_budget = width - above; // cells at thr that fit, lowest index first
// each thread a contiguous run of the CTA's keys; an odd run length keeps the shared reads conflict-free
int seg = (n + CL_T - 1) / CL_T;
if ((seg & 1) == 0 && seg > 0) ++seg;
const int s0 = t * seg < n ? t * seg : n, s1 = s0 + seg < n ? s0 + seg : n;
uint32_t gt = 0, eq = 0;
for (int i = s0; i < s1; ++i) {
const uint32_t k = keys[i], w = i == tail_i ? (uint32_t) wtail : (uint32_t) R;
if (k > thr) gt += w;
else if (k == thr) eq += w;
}
unsigned long long cta;
const unsigned long long before =
cl_excl_scan64((unsigned long long) gt | ((unsigned long long) eq << 32), s_warp, cta);
if (t < CL_N) *cl_map(&cnt[rank], (unsigned) t) = cta;
cl_arrive();
cl_wait();
int64_t gt_off = 0, eq_off = 0;
for (unsigned r = 0; r < rank; ++r) {
gt_off += (int64_t) (cnt[r] & 0xffffffffu);
eq_off += (int64_t) (cnt[r] >> 32);
}
const int64_t eq_before = eq_off + (int64_t) (before >> 32);
int64_t wpos = gt_off + (int64_t) (before & 0xffffffffu) + (eq_before < eq_budget ? eq_before : eq_budget);
int64_t eq_left = eq_budget - eq_before;
if (eq_left < 0) eq_left = 0;
for (int i = s0; i < s1; ++i) {
const uint32_t k = keys[i];
const int w = i == tail_i ? wtail : R;
const int64_t b = lo + i;
if (k > thr) {
for (int c = 0; c < w; ++c) out[wpos++] = (int32_t) (b * R + c);
} else if (k == thr) {
for (int c = 0; c < w && eq_left > 0; ++c, --eq_left) out[wpos++] = (int32_t) (b * R + c);
}
}
#else
(void) scores; (void) steps; (void) max_blocks; (void) cap; (void) ids;
__trap();
#endif
}
#endif // !__HIPCC__
} // namespace
void qsa_block_scores(const float* pooled, const float* dead, const float* q_idx, const int32_t* steps, int64_t nq,
@@ -507,6 +897,25 @@ bool qsa_block_scores_tc(const float* pooled, const float* dead, const float* q_
int64_t max_blocks, const QsaShapes& s, float* scores, void* stream, int64_t active_blocks) {
if (nq <= 0) return true;
if (s.idx_dim != IDX_DIM || s.idx_n_head != IDX_HEADS || s.idx_block != R || nq > 65535 * TC_QT) return false;
#if defined(__HIPCC__)
// AMD: the gfx12 (RDNA4) WMMA scorer, opt-in (STRATA_SELECT_WMMA=1): it selects slightly differently from the warp
// kernel (254/256 queries the same), so the default keeps the warp kernel; every other target keeps it too (false)
static const bool wmma_on = [] {
const char* v = std::getenv("STRATA_SELECT_WMMA");
return v != nullptr && v[0] != '\0' && v[0] != '0';
}();
if (!wmma_on || !sel_gfx12_device()) return false;
{
const int64_t reach = active_blocks > 0 && active_blocks < max_blocks ? active_blocks : max_blocks;
const int64_t per = (int64_t) 4 * WITER * 16;
const dim3 grid((unsigned) ((reach + per - 1) / per), (unsigned) ((nq + WQT - 1) / WQT));
block_scores_wmma_kernel<<<grid, 128, 0, (cudaStream_t) stream>>>(pooled, q_idx, steps, nq, max_blocks, reach, scores);
block_scores_tail_kernel<<<(unsigned) nq, 32, 0, (cudaStream_t) stream>>>(dead, q_idx, steps, max_blocks, scores);
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_scores_tc: %s\n", cudaGetErrorString(e)); std::exit(1); }
return true;
}
#else
{ // sm_80 or newer (TF32 MMA); an older card keeps the warp kernel
static int cc_major[64] = {};
int dev = 0;
@@ -517,13 +926,12 @@ bool qsa_block_scores_tc(const float* pooled, const float* dead, const float* q_
cudaGetLastError();
return false;
}
cc_major[dev] = major;
// STRATA_QSA_WARP=1|select (an A/B arm): the pre-sm_80 kernels on any card, as RTX 20 runs them
const char* w = std::getenv("STRATA_QSA_WARP");
cc_major[dev] = w && (!std::strcmp(w, "1") || !std::strcmp(w, "select")) ? 7 : strata::cc_major_of(major);
}
if (cc_major[dev] < 8) return false;
}
#if defined(__HIPCC__)
return false; // the tensor-core kernel is compiled out on AMD (its major version is not a CUDA sm)
#endif
static bool attr[64] = {}; // the shared-memory opt-in is per device (a layer split runs it on several)
int adev = 0;
cudaGetDevice(&adev);
@@ -545,6 +953,7 @@ bool qsa_block_scores_tc(const float* pooled, const float* dead, const float* q_
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_scores_tc: %s\n", cudaGetErrorString(e)); std::exit(1); }
return true;
#endif
}
void qsa_block_topk_ref(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap,
@@ -559,13 +968,107 @@ void qsa_block_topk_ref(const float* scores, const int32_t* steps, int64_t nq, i
if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_topk: %s\n", cudaGetErrorString(e)); std::exit(1); }
}
bool qsa_block_topk_cluster(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap,
const QsaShapes& s, int32_t* ids, void* stream) {
#if defined(__HIPCC__)
(void) scores; (void) steps; (void) nq; (void) max_blocks; (void) cap; (void) s; (void) ids; (void) stream;
return false;
#else
if (nq <= 0) return true;
if (s.idx_block != R || cap < qsa_selection_width(kTopkMaxCells, s) || nq > 65535 || max_blocks <= 0) return false;
const size_t smem = (size_t) ((max_blocks + CL_N - 1) / CL_N) * sizeof(uint32_t);
// Per device (a layer split runs on several): 1 the cluster kernel runs here, 2 it does not. It needs sm_90+ (the
// card's, or STRATA_EMULATE_CC's) AND code built for it: a build with only older code JIT-compiles their PTX, whose
// copy of this kernel is a trap - the function's PTX version says which. `opt`: the dynamic shared memory opted in.
static int ok[64] = {};
static size_t opt[64] = {};
int dev = 0;
if (cudaGetDevice(&dev) != cudaSuccess || dev < 0 || dev >= 64) { cudaGetLastError(); return false; }
if (ok[dev] == 0) {
int major = 0;
cudaFuncAttributes fa{};
const bool code = cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev) == cudaSuccess &&
strata::cc_major_of(major) >= 9 &&
cudaFuncGetAttributes(&fa, block_topk_cluster_kernel) == cudaSuccess && fa.ptxVersion >= 90 &&
fa.binaryVersion >= 90;
cudaGetLastError();
ok[dev] = code ? 1 : 2;
}
if (ok[dev] != 1) return false;
if (smem > opt[dev]) { // a larger capacity: opt in, and check that a cluster of CL_N such CTAs can be resident
int clusters = 0;
cudaLaunchConfig_t q{};
cudaLaunchAttribute qa[1];
qa[0].id = cudaLaunchAttributeClusterDimension;
qa[0].val.clusterDim.x = CL_N;
qa[0].val.clusterDim.y = 1;
qa[0].val.clusterDim.z = 1;
q.gridDim = dim3(CL_N, 1, 1);
q.blockDim = dim3(CL_T, 1, 1);
q.dynamicSmemBytes = smem;
q.attrs = qa;
q.numAttrs = 1;
if (cudaFuncSetAttribute(block_topk_cluster_kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, (int) smem) !=
cudaSuccess ||
cudaOccupancyMaxActiveClusters(&clusters, block_topk_cluster_kernel, &q) != cudaSuccess || clusters < 1) {
cudaGetLastError();
return false; // this capacity takes the one-CTA kernels; a smaller one may still fit
}
opt[dev] = smem;
}
cudaLaunchConfig_t cfg{};
cudaLaunchAttribute at[1];
at[0].id = cudaLaunchAttributeClusterDimension;
at[0].val.clusterDim.x = CL_N;
at[0].val.clusterDim.y = 1;
at[0].val.clusterDim.z = 1;
cfg.gridDim = dim3(CL_N, (unsigned) nq, 1);
cfg.blockDim = dim3(CL_T, 1, 1);
cfg.dynamicSmemBytes = smem;
cfg.stream = (cudaStream_t) stream;
cfg.attrs = at;
cfg.numAttrs = 1;
const cudaError_t e = cudaLaunchKernelEx(&cfg, block_topk_cluster_kernel, scores, steps, max_blocks, cap, ids);
if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_topk cluster: %s\n", cudaGetErrorString(e)); std::exit(1); }
return true;
#endif
}
void qsa_block_topk(const float* scores, const int32_t* steps, int64_t nq, int64_t max_blocks, int64_t cap,
const QsaShapes& s, int32_t* ids, void* stream) {
const QsaShapes& s, int32_t* ids, void* stream, int64_t active_blocks) {
// keys in registers when every query's blocks fit (contexts up to ~135K cells); the same ids. STRATA_TOPK_OLD=1:
// the kernel that reads them from memory on every pass
static const bool old = std::getenv("STRATA_TOPK_OLD") != nullptr;
if (nq <= 0) return;
if (old || max_blocks > (int64_t) TK_T * TK_PER) {
#if !defined(__HIPCC__)
// sm_90+: a cluster of CL_N CTAs per query for the calls of a few queries (decode windows); the same ids.
// STRATA_QSA_CLUSTER=0: the one-CTA kernels below
static const bool cluster = [] {
const char* v = std::getenv("STRATA_QSA_CLUSTER");
return !v || std::atoi(v) != 0;
}();
if (cluster && !old && nq <= CL_MAXQ && qsa_block_topk_cluster(scores, steps, nq, max_blocks, cap, s, ids, stream))
return;
#endif
// the blocks a query can have: the call's active count when the caller knows it (the prompt path), else the capacity.
// Decode (no count) keeps the capacity rule and the original register width: nothing changes there.
#if defined(__HIPCC__)
const bool counted = active_blocks > 0;
#else
// CUDA keeps 0.1.32's capacity rule: #337's dispatch was measured on RDNA4 only, and on the RTX 5070 the 64K
// prompts read 1-3% slower with it
const bool counted = false;
(void) active_blocks;
#endif
const int64_t reach = counted && active_blocks < max_blocks ? active_blocks : max_blocks;
const int64_t fit = (int64_t) TK_T * (counted ? TK_PER_MAX : TK_PER);
#if defined(__HIPCC__)
constexpr int64_t kRegMinBlocks = 7168; // gfx1201: below ~28K cells the 1,024-thread kernel's fixed cost loses to the ref
const bool too_small = counted && reach < kRegMinBlocks;
#else
const bool too_small = false;
#endif
if (old || too_small || reach > fit) {
qsa_block_topk_ref(scores, steps, nq, max_blocks, cap, s, ids, stream);
return;
}
@@ -573,7 +1076,10 @@ void qsa_block_topk(const float* scores, const int32_t* steps, int64_t nq, int64
std::fprintf(stderr, "qsa_block_topk: unsupported geometry or cap\n");
std::exit(1);
}
block_topk_reg_kernel<<<(unsigned) nq, TK_T, 0, (cudaStream_t) stream>>>(scores, steps, max_blocks, cap, ids);
if (reach <= (int64_t) TK_T * TK_PER)
block_topk_reg_kernel<TK_PER><<<(unsigned) nq, TK_T, 0, (cudaStream_t) stream>>>(scores, steps, max_blocks, cap, ids);
else
block_topk_reg_kernel<TK_PER_MAX><<<(unsigned) nq, TK_T, 0, (cudaStream_t) stream>>>(scores, steps, max_blocks, cap, ids);
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) { std::fprintf(stderr, "qsa_block_topk: %s\n", cudaGetErrorString(e)); std::exit(1); }
}
+175
View File
@@ -186,10 +186,185 @@ __global__ void router_top10_kernel(const float* __restrict__ logits, int n_toke
}
}
#if defined(__HIPCC__)
// ---- S6, AMD: the same router, BIT-IDENTICAL, without its serial parts. Measured on RDNA4 (gfx1201) the kernel
// above took 39 us per call in decode (16% of the GPU's decode time): the ascending double sum is 512 dependent FP64
// adds (~50 cycles each there), the ten selection passes are 20 block barriers, and the renormalisation re-reads its
// ten weights from global memory one by one. Here:
// * the sum's ONLY use is inv = (float) (1.0 / sum). Every term is positive and the largest is exactly 1
// (exp(0)), so the serial sum and any other summation order both lie within 2^-43 (relative) of the exact sum
// (|serial - exact| <= 511u, |tree - exact| <= 21u, u = 2^-53). 1.0 / x and the float cast are monotone: when
// the bracket [tree (1 - 2^-40), tree (1 + 2^-40)] maps to ONE float, that float is the serial sum's. Otherwise
// (about 1 row in 1000 in the parity test, or a non-finite sum) thread 0 runs the serial sum as before;
// * the selection runs on wave 0 alone with the probabilities in registers (16 per lane): the largest p with the
// lowest index on ties is unique, so the butterfly finds the same expert as the block reduction did;
// * the renormalisation sums the ten weights in rank order from registers and divides them in parallel.
// Same expressions, same operands: the ids and weights are bitwise those of router_top10_kernel (hip_router_fast
// checks it on 65,536 rows with ties, near-ties, NaN rows and the forced serial path). STRATA_HIP_ROUTER_OLD=1: the
// kernel above.
template <bool FORCE_SERIAL>
__global__ void router_top10_fast_kernel(const float* __restrict__ logits, int n_tokens, int n_expert, int k,
int* __restrict__ ids, float* __restrict__ weights) {
extern __shared__ unsigned char s_raw[];
const size_t taken_bytes = ((size_t) n_expert + 15u) & ~(size_t) 15u;
double* s_ex = (double*) (s_raw + taken_bytes);
__shared__ float s_red[RT_MAX_THREADS / 32];
__shared__ double s_dred[RT_MAX_THREADS / 32];
__shared__ float s_inv;
const int t = blockIdx.x;
if (t >= n_tokens) return;
const int tid = threadIdx.x, lane = tid & 31, warp = tid >> 5;
const int nt = blockDim.x, nw = (nt + 31) >> 5;
const float* l = logits + (size_t) t * n_expert;
// the max: as router_top10_kernel (exact, order-independent)
float mx = -INFINITY;
for (int e = tid; e < n_expert; e += nt) mx = fmaxf(mx, l[e]);
for (int off = 16; off > 0; off >>= 1) mx = fmaxf(mx, __shfl_down_sync(0xffffffffu, mx, off));
if (lane == 0) s_red[warp] = mx;
__syncthreads();
if (tid < 32) {
float v = (tid < nw) ? s_red[tid] : -INFINITY;
for (int off = 16; off > 0; off >>= 1) v = fmaxf(v, __shfl_down_sync(0xffffffffu, v, off));
if (tid == 0) s_red[0] = v;
}
__syncthreads();
mx = s_red[0];
// the exponentials (the same expression), and a tree sum of them for the bracket
double part = 0.0;
for (int e = tid; e < n_expert; e += nt) {
const double x = exp((double) l[e] - (double) mx);
s_ex[e] = x;
part += x;
}
for (int off = 16; off > 0; off >>= 1) part += __shfl_xor_sync(0xffffffffu, part, off);
if (lane == 0) s_dred[warp] = part;
__syncthreads();
double tot = 0.0;
for (int w = 0; w < nw; ++w) tot += s_dred[w];
const double lo = tot * (1.0 - 0x1p-40), hi = tot * (1.0 + 0x1p-40);
const float f_lo = (float) (1.0 / hi), f_hi = (float) (1.0 / lo);
float inv;
if (!FORCE_SERIAL && tot >= 1.0 && hi < 1e300 && f_lo == f_hi) {
inv = f_lo;
} else { // block-uniform (every thread computed the same tot): the serial sum, exactly as router_top10_kernel
if (tid == 0) {
double sum = 0.0;
for (int e = 0; e < n_expert; ++e) sum += s_ex[e];
s_inv = (float) (1.0 / sum);
}
__syncthreads();
inv = s_inv;
}
if (warp != 0) return;
// wave 0: lane holds experts lane + 32 j; p is router_top10_kernel's expression
constexpr int PER = 16;
float pv[PER];
unsigned live = 0;
#pragma unroll
for (int j = 0; j < PER; ++j) {
const int e = lane + 32 * j;
pv[j] = -INFINITY;
if (e < n_expert) { pv[j] = (float) (s_ex[e] * inv); live |= 1u << j; }
}
float my_w = 0.0f;
int my_id = 0, nsel = 0;
for (int i = 0; i < k; ++i) {
float bv = -INFINITY;
int bi = n_expert; // a sentinel that loses to every real index
#pragma unroll
for (int j = 0; j < PER; ++j)
if (((live >> j) & 1u) && pv[j] > bv) { bv = pv[j]; bi = lane + 32 * j; }
for (int off = 16; off > 0; off >>= 1) {
const float ov = __shfl_xor_sync(0xffffffffu, bv, off);
const int oi = __shfl_xor_sync(0xffffffffu, bi, off);
if (ov > bv || (ov == bv && oi < bi)) { bv = ov; bi = oi; }
}
if (bi >= n_expert) break; // as router_top10_kernel: no rank i or later is written (every p left is NaN)
if ((bi & 31) == lane) live &= ~(1u << (bi >> 5));
if (lane == i) { my_w = bv; my_id = bi; }
nsel = i + 1;
}
if (nsel == k) {
double s = 0.0;
for (int i = 0; i < k; ++i) s += (double) __shfl_sync(0xffffffffu, my_w, i);
const double sc = fmax(s, 6.103515625e-05); // 2**-14
if (lane < k) {
ids[(size_t) t * k + lane] = my_id;
weights[(size_t) t * k + lane] = (float) ((double) my_w / sc);
}
} else { // router_top10_kernel's tail exactly: unwritten ranks keep what the buffer held and are renormalised too
if (lane < nsel) { ids[(size_t) t * k + lane] = my_id; weights[(size_t) t * k + lane] = my_w; }
__threadfence();
if (lane == 0) {
double s = 0.0;
for (int i = 0; i < k; ++i) s += (double) weights[(size_t) t * k + i];
const double sc = fmax(s, 6.103515625e-05);
for (int i = 0; i < k; ++i)
weights[(size_t) t * k + i] = (float) ((double) weights[(size_t) t * k + i] / sc);
}
}
}
void launch_generic(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights, void* stream,
int mode) { // mode 0: router_top10_kernel, 1: the HIP fast kernel, 2: the fast kernel's serial path
int threads = n_expert < RT_MAX_THREADS ? n_expert : RT_MAX_THREADS;
threads = (threads + 31) & ~31;
const size_t taken_bytes = ((size_t) n_expert + 15u) & ~(size_t) 15u;
const size_t smem = taken_bytes + (size_t) n_expert * sizeof(double) + (size_t) n_expert * sizeof(float);
if (mode == 1) {
router_top10_fast_kernel<false><<<(unsigned) n_tokens, threads, smem, (cudaStream_t) stream>>>(
logits, n_tokens, n_expert, k, ids, weights);
return;
}
if (mode == 2) {
router_top10_fast_kernel<true><<<(unsigned) n_tokens, threads, smem, (cudaStream_t) stream>>>(
logits, n_tokens, n_expert, k, ids, weights);
return;
}
router_top10_kernel<<<(unsigned) n_tokens, threads, smem, (cudaStream_t) stream>>>(
logits, n_tokens, n_expert, k, ids, weights);
}
#endif
} // namespace
bool router_top10_variant(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights,
void* stream, int variant) {
#if defined(__HIPCC__)
if (n_tokens <= 0 || n_expert <= 64 || n_expert > 512 || k <= 0 || k > 32) return false;
if (variant < 0 || variant > 2) return false;
launch_generic(logits, n_tokens, n_expert, k, ids, weights, stream, variant);
return cudaGetLastError() == cudaSuccess;
#else
(void) logits; (void) n_tokens; (void) n_expert; (void) k; (void) ids; (void) weights; (void) stream;
(void) variant;
return false;
#endif
}
void router_top10(const float* logits, int n_tokens, int n_expert, int k, int* ids, float* weights,
void* stream) {
#if defined(__HIPCC__)
{
static const bool old = std::getenv("STRATA_HIP_ROUTER_OLD") != nullptr;
if (!old && n_tokens > 0 && n_expert > 64 && n_expert <= 512 && k > 0 && k <= 32) {
launch_generic(logits, n_tokens, n_expert, k, ids, weights, stream, 1);
const cudaError_t e = cudaGetLastError();
if (e != cudaSuccess) {
std::fprintf(stderr, "router_top10 launch: %s\n", cudaGetErrorString(e));
std::exit(1);
}
if (stream == nullptr) {
const cudaError_t s = cudaDeviceSynchronize();
if (s != cudaSuccess) {
std::fprintf(stderr, "router_top10: %s\n", cudaGetErrorString(s));
std::exit(1);
}
}
return;
}
}
#endif
if (n_tokens <= 0 || n_expert <= 0 || k <= 0) return;
if (k > 64) {
std::fprintf(stderr, "router_top10: k %d exceeds the kernel's 64\n", k);
+151 -3
View File
@@ -10,7 +10,8 @@
// produces a valid token, so only a comparison at the distribution level can tell them apart; the parity test
// does that against an independently computed distribution.
//
// `sampler_greedy_kernel` is the plain argmax, one block per token over the vocabulary. The sampled chain has
// `sampler_greedy_kernel` is the plain argmax, one block per token over the vocabulary (on sm_90+ without penalties,
// `sampler_greedy_cluster_kernel`: the same token from a cluster of 8 CTAs per row). The sampled chain has
// three implementations that pick the same token, bit for bit:
// - the SPLIT top_k (default): `sampler_split_part_kernel` cuts each row into 4,096-logit blocks over the whole
// GPU, each keeps its own top_k, and `sampler_split_merge_kernel` merges those lists and runs the tail;
@@ -20,6 +21,7 @@
// The two new ones share `sampled_tail_warp` (top_p / min_p / temperature / draw on one warp).
#include "strata/kernels/sampler.hpp"
#include "strata/core/coupled_draft.hpp"
#include "strata/core/emulate.hpp"
#include <cuda_runtime.h>
@@ -166,6 +168,96 @@ __global__ void sampler_greedy_kernel(const float* __restrict__ logits, int n_vo
}
}
#if !defined(__HIPCC__)
/// **THE SAME ARGMAX ON A THREAD-BLOCK CLUSTER (sm_90+, S19), FOR THE CALLS WITHOUT PENALTIES.** One block per
/// token reads its 1 MB row (248,320 logits) on one SM with one load in flight per thread: latency-bound, 40 us per
/// call on an RTX 5070, for every verify window and every draft step. Here a cluster of `kAmCtas` CTAs shares the
/// row, each thread keeps four loads in flight, and the CTAs' results meet in CTA 0's shared memory (distributed
/// shared memory): 6 us (decode_cluster_parity --bench).
///
/// The answer is a function of the row alone, so it is the one-block kernel's bit for bit: the LOWEST index whose value
/// is the largest non-NaN value above -inf (each thread walks its elements in ascending order with a strict `>`, and
/// every merge takes the larger value or, on equality, the smaller index - an order-free rule), and 0 when there is
/// none (all -inf / NaN), as there. NaN never wins a `>`.
///
/// Barriers: a relaxed cluster arrive at entry, waited before the remote store (CTA 0 must be running); each CTA's
/// result goes to slot `rank` of CTA 0, released by the next arrive; CTAs 1.. then exit (a cluster wait counts the
/// threads that have not exited) and CTA 0 waits, reads its own slots, and writes the token.
constexpr int kAmCtas = 8; // CTAs per token (the portable cluster size)
constexpr int kAmThreads = 1024;
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 900
#define STRATA_AM_CLUSTER 1
#else
#define STRATA_AM_CLUSTER 0 // older targets: a trap, never launched (sample_greedy_cluster checks)
#endif
#if STRATA_AM_CLUSTER
__device__ __forceinline__ void am_take(float s, int v, float& bv, int& best) {
if (s > bv) { bv = s; best = v; }
}
__device__ __forceinline__ void am_merge(float ov, int oi, float& bv, int& best) {
if (ov > bv || (ov == bv && oi < best)) { bv = ov; best = oi; }
}
#endif
// grid (kAmCtas, n_tokens), cluster (kAmCtas, 1, 1), kAmThreads threads
__global__ void __launch_bounds__(kAmThreads) sampler_greedy_cluster_kernel(const float* __restrict__ logits,
int n_vocab, int* __restrict__ out) {
#if STRATA_AM_CLUSTER
__shared__ float sv[32];
__shared__ int si[32];
__shared__ float cv[kAmCtas]; // on CTA 0: each CTA's result
__shared__ int ci[kAmCtas];
unsigned rank;
asm volatile("mov.u32 %0, %%cluster_ctarank;\n" : "=r"(rank));
asm volatile("barrier.cluster.arrive.relaxed.aligned;\n" ::: "memory");
const float* l = logits + (size_t) blockIdx.y * n_vocab;
float bv = __int_as_float(0xff800000); // -inf; `n_vocab` is "no candidate", as in sampler_greedy_kernel
int best = n_vocab;
constexpr int S = kAmCtas * kAmThreads;
int v = (int) rank * kAmThreads + (int) threadIdx.x;
for (; v + 3 * S < n_vocab; v += 4 * S) { // four independent loads, then taken in ascending order
const float x0 = l[v], x1 = l[v + S], x2 = l[v + 2 * S], x3 = l[v + 3 * S];
am_take(x0, v, bv, best);
am_take(x1, v + S, bv, best);
am_take(x2, v + 2 * S, bv, best);
am_take(x3, v + 3 * S, bv, best);
}
for (; v < n_vocab; v += S) am_take(l[v], v, bv, best);
for (int off = 16; off > 0; off >>= 1)
am_merge(__shfl_down_sync(0xFFFFFFFFu, bv, off), __shfl_down_sync(0xFFFFFFFFu, best, off), bv, best);
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) {
bv = sv[lane];
best = si[lane];
for (int off = 16; off > 0; off >>= 1)
am_merge(__shfl_down_sync(0xFFFFFFFFu, bv, off), __shfl_down_sync(0xFFFFFFFFu, best, off), bv, best);
}
asm volatile("barrier.cluster.wait.acquire.aligned;\n" ::: "memory"); // CTA 0 runs
if (threadIdx.x == 0) {
uint64_t a;
asm volatile("mapa.u64 %0, %1, %2;\n" : "=l"(a) : "l"((uint64_t) &cv[rank]), "r"(0u));
*reinterpret_cast<float*>(a) = bv;
asm volatile("mapa.u64 %0, %1, %2;\n" : "=l"(a) : "l"((uint64_t) &ci[rank]), "r"(0u));
*reinterpret_cast<int*>(a) = best;
}
asm volatile("barrier.cluster.arrive.release.aligned;\n" ::: "memory");
if (rank != 0) return;
asm volatile("barrier.cluster.wait.acquire.aligned;\n" ::: "memory");
if (warp == 0) {
bv = lane < kAmCtas ? cv[lane] : __int_as_float(0xff800000);
best = lane < kAmCtas ? ci[lane] : n_vocab;
for (int off = 16; off > 0; off >>= 1)
am_merge(__shfl_down_sync(0xFFFFFFFFu, bv, off), __shfl_down_sync(0xFFFFFFFFu, best, off), bv, best);
if (lane == 0) out[blockIdx.y] = (best < n_vocab) ? best : 0;
}
#else
(void) logits; (void) n_vocab; (void) out;
__trap();
#endif
}
#endif // !__HIPCC__
/// **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
@@ -833,6 +925,55 @@ int2* split_scratch(void* stream, size_t entries) {
} // namespace
bool sample_greedy_cluster(const float* logits, int n_tokens, int n_vocab, int* out, void* stream) {
#if defined(__HIPCC__)
(void) logits; (void) n_tokens; (void) n_vocab; (void) out; (void) stream;
return false;
#else
if (n_tokens <= 0 || n_vocab <= 0) return true;
if (n_tokens > 65535) return false;
// Per device (a layer split runs on several): 1 the cluster kernel runs here, 2 it does not - sm_90+ (the card's,
// or STRATA_EMULATE_CC's), code built for it (an older build's PTX holds a trap: the PTX version says which), and
// room for one cluster of kAmCtas CTAs.
static int ok[64] = {};
int dev = 0;
if (cudaGetDevice(&dev) != cudaSuccess || dev < 0 || dev >= 64) { cudaGetLastError(); return false; }
cudaLaunchConfig_t cfg{};
cudaLaunchAttribute at[1];
at[0].id = cudaLaunchAttributeClusterDimension;
at[0].val.clusterDim.x = kAmCtas;
at[0].val.clusterDim.y = 1;
at[0].val.clusterDim.z = 1;
cfg.gridDim = dim3(kAmCtas, 1, 1);
cfg.blockDim = dim3(kAmThreads, 1, 1);
cfg.dynamicSmemBytes = 0;
cfg.stream = (cudaStream_t) stream;
cfg.attrs = at;
cfg.numAttrs = 1;
if (ok[dev] == 0) {
int major = 0, clusters = 0;
cudaFuncAttributes fa{};
const bool runs = cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev) == cudaSuccess &&
strata::cc_major_of(major) >= 9 &&
cudaFuncGetAttributes(&fa, sampler_greedy_cluster_kernel) == cudaSuccess &&
fa.ptxVersion >= 90 && fa.binaryVersion >= 90 &&
cudaOccupancyMaxActiveClusters(&clusters, sampler_greedy_cluster_kernel, &cfg) ==
cudaSuccess &&
clusters >= 1;
cudaGetLastError();
ok[dev] = runs ? 1 : 2;
}
if (ok[dev] != 1) return false;
cfg.gridDim = dim3(kAmCtas, (unsigned) n_tokens, 1);
const cudaError_t e = cudaLaunchKernelEx(&cfg, sampler_greedy_cluster_kernel, logits, n_vocab, out);
if (e != cudaSuccess) {
std::fprintf(stderr, "sample_tokens cluster launch: %s\n", cudaGetErrorString(e));
std::exit(1);
}
return true;
#endif
}
void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* history, int history_len,
const SamplerParams& p, int* out, void* stream) {
if (n_tokens <= 0 || n_vocab <= 0) return;
@@ -845,10 +986,17 @@ void sample_tokens(const float* logits, int n_tokens, int n_vocab, const int* hi
? (unsigned) ((n_vocab + 31) / 32) * sizeof(unsigned) // the penalty bitmap
: 0;
if (p.greedy || p.temperature <= 0.0f) {
// Without penalties (shmem == 0: no window) on sm_90+, a cluster of CTAs per token - the same token; see
// `sampler_greedy_cluster_kernel`. STRATA_ARGMAX_MULTI=0: always the one-block kernel.
static const bool multi = [] {
const char* v = std::getenv("STRATA_ARGMAX_MULTI");
return !v || std::atoi(v) != 0;
}();
// 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, shmem, (cudaStream_t) stream>>>(
logits, n_vocab, history, history_len, p, p.penalty_last_n, p.penalty_last_n, out);
if (!(multi && shmem == 0 && sample_greedy_cluster(logits, n_tokens, n_vocab, out, 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 if (sampled_path() == SampledPath::Old) {
// 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.
+1 -1
View File
@@ -546,7 +546,7 @@ void copy_indexed(float* dst, const float* src, int64_t stride, const int32_t* i
namespace { __global__ void gpu_stamp_kernel(unsigned long long* buf, int i) {
unsigned long long t;
#if defined(__HIPCC__)
t = wall_clock64() * 10ull; // gfx11 / gfx12: a constant 100 MHz counter, in ns
t = wall_clock64() * 10ull; // gfx10.3 / gfx11 / gfx12: a constant 100 MHz counter, in ns
#else
asm volatile("mov.u64 %0, %%globaltimer;" : "=l"(t));
#endif
+421
View File
@@ -0,0 +1,421 @@
// src/kernels/decode_cluster_parity.cpp - S19: the decode kernels on thread-block clusters (sm_90+) against the ones
// they replace, bit for bit (GPU, synthetic, no model).
//
// QSA top-k : qsa_block_topk_cluster vs qsa_block_topk's one-CTA dispatch (the register kernel up to 33,792 blocks,
// block_topk_kernel above) and vs qsa_block_topk_ref - every id of every row of the `cap`-wide buffer,
// including the entries past the width that neither may write.
// argmax : sample_greedy_cluster vs sample_tokens' one-block greedy kernel.
//
// Inputs: random scores like a real indexer's, and adversarial ones - all equal, a few distinct values (ties at the
// threshold across the CTAs of a cluster), NaN, +-inf, -0.0 against +0.0, denormals, monotone rows, all NaN; contexts
// from below the selection width (the identity) to 262,144 cells, every tail weight (n_kv mod 4), 1-16 queries; and
// one CUDA graph that captures both cluster launches, replayed with new inputs.
//
// decode_cluster_parity --selftest parity (exit 0 = identical everywhere); skipped (exit 0) below sm_90
// decode_cluster_parity --bench per-call times, old vs new, at 32K / 128K / 262K
//
// The old paths are reached through the public dispatchers with STRATA_QSA_CLUSTER=0 / STRATA_ARGMAX_MULTI=0, set
// here before their first call (they read the variables once).
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/qsa_select.hpp"
#include "strata/kernels/sampler.hpp"
#include <cuda_runtime.h>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <limits>
#include <random>
#include <string>
#include <vector>
namespace k = strata::kernels;
namespace {
void ck(cudaError_t e, const char* what) {
if (e != cudaSuccess) {
std::fprintf(stderr, "%s: %s\n", what, cudaGetErrorString(e));
std::exit(2);
}
}
void set_env(const char* name, const char* value) {
#if defined(_WIN32)
_putenv_s(name, value);
#else
setenv(name, value, 1);
#endif
}
template <typename T> struct Dev {
T* p = nullptr;
size_t n = 0;
explicit Dev(size_t count) : n(count) { ck(cudaMalloc(&p, count * sizeof(T) + 64), "cudaMalloc"); }
~Dev() { cudaFree(p); }
void put(const std::vector<T>& h) {
ck(cudaMemcpy(p, h.data(), h.size() * sizeof(T), cudaMemcpyHostToDevice), "up");
}
std::vector<T> get() const {
std::vector<T> h(n);
ck(cudaMemcpy(h.data(), p, n * sizeof(T), cudaMemcpyDeviceToHost), "down");
return h;
}
};
const float kNaN = std::numeric_limits<float>::quiet_NaN(), kInf = std::numeric_limits<float>::infinity();
// ---- QSA top-k fixtures
enum Pattern { kRandom, kAllEqual, kFewValues, kNaNs, kInfs, kSignedZero, kDenormal, kAscending, kDescending, kAllNaN,
kQuantized, kPatterns };
const char* pattern_name(int p) {
static const char* n[] = {"random", "all-equal", "few-values", "nan", "inf", "signed-zero", "denormal", "ascending",
"descending", "all-nan", "quantized"};
return n[p];
}
// one query's row of block scores (blocks 0..n_bid); the tail block gets the +1e9 the score kernel gives it
void fill_row(float* row, int64_t n_kv, int64_t n_bid, int pattern, std::mt19937& rng) {
std::normal_distribution<float> nd(0.f, 1.f);
std::uniform_int_distribution<int> pick(0, 99);
for (int64_t b = 0; b <= n_bid; ++b) {
float v = 0.f;
const float g = nd(rng);
switch (pattern) {
case kRandom: v = std::fmax(0.f, g) * 3.f + std::fmax(0.f, nd(rng)); break; // relu sums: exact zeros too
case kAllEqual: v = 1.f; break;
case kFewValues: v = (float) (pick(rng) % 4) * 0.5f; break;
case kNaNs: v = pick(rng) < 15 ? kNaN : std::fabs(g); break;
case kInfs: {
const int r = pick(rng);
v = r < 5 ? kInf : r < 15 ? -kInf : r < 30 ? -std::fabs(g) : g;
break;
}
case kSignedZero: v = pick(rng) < 50 ? -0.0f : (pick(rng) < 50 ? 0.0f : -std::fabs(g) * 1e-3f); break;
case kDenormal: v = (float) (pick(rng) % 7) * 1e-41f; break;
case kAscending: v = (float) b; break;
case kDescending: v = (float) (n_bid - b); break;
case kAllNaN: v = kNaN; break;
case kQuantized: v = std::round(std::fabs(g) * 8.f) / 8.f; break;
}
row[b] = v;
}
if (n_kv % 4 != 0 && pattern != kAllNaN && pattern != kAllEqual) row[n_bid] += 1e9f;
}
struct TopkCase {
int64_t ctx; // the last query's n_kv
int64_t capacity; // --max-context: max_blocks = capacity / 4 + 2 (the engine's rule)
int nq;
int pattern;
};
int run_topk_case(const TopkCase& c, std::mt19937& rng, bool verbose) {
const k::QsaShapes s = k::qsa_real_shapes();
const int64_t max_blocks = c.capacity / 4 + 2, cap = k::qsa_selection_width(k::kTopkMaxCells, s);
std::vector<float> sc((size_t) (c.nq * max_blocks), 0.f);
std::vector<int32_t> steps((size_t) c.nq * k::kStepCount);
for (int i = 0; i < c.nq; ++i) {
const int64_t n_kv = c.ctx - (c.nq - 1 - i); // the window's queries, one cell apart, the last at ctx
int32_t* st = steps.data() + (size_t) i * k::kStepCount;
st[k::kStepPos] = (int32_t) (n_kv - 1);
st[k::kStepNKv] = (int32_t) n_kv;
st[k::kStepNBid] = (int32_t) (n_kv / s.idx_block);
st[k::kStepWidth] = (int32_t) k::qsa_selection_width(n_kv, s);
fill_row(sc.data() + (size_t) i * max_blocks, n_kv, n_kv / s.idx_block, c.pattern, rng);
}
Dev<float> d_sc(sc.size());
const size_t n_ids = (size_t) (c.nq * cap);
Dev<int32_t> d_st(steps.size()), d_new(n_ids), d_old(n_ids), d_ref(n_ids);
d_sc.put(sc);
d_st.put(steps);
ck(cudaMemset(d_new.p, 0xA5, d_new.n * 4), "memset");
ck(cudaMemset(d_old.p, 0xA5, d_old.n * 4), "memset");
ck(cudaMemset(d_ref.p, 0xA5, d_ref.n * 4), "memset");
if (!k::qsa_block_topk_cluster(d_sc.p, d_st.p, c.nq, max_blocks, cap, s, d_new.p, nullptr)) {
std::printf("FAIL topk ctx %lld cap %lld nq %d: the cluster kernel did not run\n", (long long) c.ctx,
(long long) c.capacity, c.nq);
return 1;
}
k::qsa_block_topk(d_sc.p, d_st.p, c.nq, max_blocks, cap, s, d_old.p, nullptr);
k::qsa_block_topk_ref(d_sc.p, d_st.p, c.nq, max_blocks, cap, s, d_ref.p, nullptr);
ck(cudaDeviceSynchronize(), "topk sync");
const std::vector<int32_t> a = d_new.get(), b = d_old.get(), r = d_ref.get();
int64_t diff_old = 0, diff_ref = 0, first = -1;
for (size_t i = 0; i < a.size(); ++i) {
if (a[i] != b[i]) { ++diff_old; if (first < 0) first = (int64_t) i; }
if (a[i] != r[i]) ++diff_ref;
}
const bool ok = diff_old == 0 && diff_ref == 0;
if (!ok || verbose)
std::printf("%s topk ctx %7lld cap %7lld nq %2d %-11s: %lld ids differ from the one-CTA dispatch, %lld from "
"the reference%s\n", ok ? "ok " : "FAIL", (long long) c.ctx, (long long) c.capacity, c.nq,
pattern_name(c.pattern), (long long) diff_old, (long long) diff_ref,
first >= 0 ? (" (first at " + std::to_string(first) + ")").c_str() : "");
return ok ? 0 : 1;
}
// ---- argmax fixtures
enum AmPattern { aRandom, aTies, aAllEqual, aAllNegInf, aAllNaN, aNaNMax, aPosInf, aSignedZero, aLast, aFirst,
aDenormal, aTiesAcross, aPatterns };
const char* am_name(int p) {
static const char* n[] = {"random", "ties", "all-equal", "all--inf", "all-nan", "nan+max", "+inf", "signed-zero",
"max-last", "max-first", "denormal", "ties-across"};
return n[p];
}
void fill_logits(float* l, int nv, int pattern, std::mt19937& rng) {
std::normal_distribution<float> nd(0.f, 3.f);
std::uniform_int_distribution<int> at(0, nv - 1);
for (int v = 0; v < nv; ++v) l[v] = nd(rng);
switch (pattern) {
case aRandom: break;
case aTies: { const int n = 1 + at(rng) % 9; for (int i = 0; i < n; ++i) l[at(rng)] = 100.f; break; }
case aAllEqual: for (int v = 0; v < nv; ++v) l[v] = 0.25f; break;
case aAllNegInf: for (int v = 0; v < nv; ++v) l[v] = -kInf; break;
case aAllNaN: for (int v = 0; v < nv; ++v) l[v] = kNaN; break;
case aNaNMax:
for (int v = 0; v < nv; ++v) if (at(rng) % 3 == 0) l[v] = kNaN;
l[at(rng)] = 50.f;
l[at(rng)] = 50.f;
break;
case aPosInf: for (int i = 0; i < 4; ++i) l[at(rng)] = kInf; break;
case aSignedZero:
for (int v = 0; v < nv; ++v) l[v] = -std::fabs(l[v]) - 1.f;
for (int i = 0; i < 6; ++i) l[at(rng)] = (i & 1) ? 0.0f : -0.0f;
break;
case aLast: l[nv - 1] = 1e30f; break;
case aFirst: l[0] = 1e30f; if (nv > 1) l[nv - 1] = 1e30f; break;
case aDenormal: for (int v = 0; v < nv; ++v) l[v] = (float) (at(rng) % 5) * 1e-42f - 1e-30f * (v & 1); break;
case aTiesAcross: // the same maximum in several CTAs' and threads' ranges (8 x 1024 stride), plus -inf, NaN
for (int v = 0; v < nv; v += 1 + at(rng) % 9000) l[v] = 7.f;
for (int v = 3; v < nv; v += 11) l[v] = kNaN;
for (int v = 5; v < nv; v += 13) l[v] = -kInf;
break;
}
}
int run_argmax_case(int nv, int T, int pattern, std::mt19937& rng, bool verbose) {
std::vector<float> l((size_t) nv * T);
for (int t = 0; t < T; ++t) fill_logits(l.data() + (size_t) t * nv, nv, t % 2 == 0 ? pattern : aRandom, rng);
Dev<float> d_l(l.size());
Dev<int> d_new((size_t) T), d_old((size_t) T);
d_l.put(l);
ck(cudaMemset(d_new.p, 0xA5, (size_t) T * 4), "memset");
ck(cudaMemset(d_old.p, 0x5A, (size_t) T * 4), "memset");
k::SamplerParams sp;
sp.greedy = true;
sp.temperature = 0.f;
if (!k::sample_greedy_cluster(d_l.p, T, nv, d_new.p, nullptr)) {
std::printf("FAIL argmax nv %d T %d: the cluster kernel did not run\n", nv, T);
return 1;
}
k::sample_tokens(d_l.p, T, nv, nullptr, 0, sp, d_old.p, nullptr);
ck(cudaDeviceSynchronize(), "argmax sync");
const std::vector<int> a = d_new.get(), b = d_old.get();
// and an independent host answer: the lowest index of the largest non-NaN value above -inf, else 0
int bad = 0;
for (int t = 0; t < T; ++t) {
const float* r = l.data() + (size_t) t * nv;
int hb = -1;
for (int v = 0; v < nv; ++v)
if (r[v] > -kInf && (hb < 0 || r[v] > r[hb])) hb = v;
if (hb < 0) hb = 0;
if (a[(size_t) t] != b[(size_t) t] || a[(size_t) t] != hb) ++bad;
}
if (bad || verbose)
std::printf("%s argmax nv %6d T %d %-11s: %d rows differ (new %d, old %d)\n", bad ? "FAIL" : "ok ", nv, T,
am_name(pattern), bad, a[0], b[0]);
return bad ? 1 : 0;
}
// both cluster launches captured in one graph, replayed over new inputs written into the same buffers
int run_graph_case(std::mt19937& rng) {
const k::QsaShapes s = k::qsa_real_shapes();
const int nq = 5, nv = 248320;
const int64_t capacity = 131072 + 272, max_blocks = capacity / 4 + 2;
const int64_t cap = k::qsa_selection_width(k::kTopkMaxCells, s);
Dev<float> d_sc((size_t) (nq * max_blocks)), d_l((size_t) nv * nq);
Dev<int32_t> d_st((size_t) nq * k::kStepCount), d_new((size_t) (nq * cap)), d_old((size_t) (nq * cap));
Dev<int> a_new((size_t) nq), a_old((size_t) nq);
ck(cudaMemset(d_new.p, 0, d_new.n * 4), "memset"); // the entries past a row's width: written by neither
ck(cudaMemset(d_old.p, 0, d_old.n * 4), "memset");
cudaStream_t cs;
ck(cudaStreamCreateWithFlags(&cs, cudaStreamNonBlocking), "stream");
cudaGraph_t g = nullptr;
cudaGraphExec_t ge = nullptr;
ck(cudaStreamBeginCapture(cs, cudaStreamCaptureModeThreadLocal), "begin capture");
const bool l1 = k::qsa_block_topk_cluster(d_sc.p, d_st.p, nq, max_blocks, cap, s, d_new.p, cs);
const bool l2 = k::sample_greedy_cluster(d_l.p, nq, nv, a_new.p, cs);
ck(cudaStreamEndCapture(cs, &g), "end capture");
if (!l1 && !l2) { // no cluster code here (a card or a build below sm_90): nothing to compare
if (g) cudaGraphDestroy(g);
cudaStreamDestroy(cs);
return -1;
}
if (!l1 || !l2) { std::printf("FAIL graph: one cluster kernel did not run\n"); return 1; }
ck(cudaGraphInstantiate(&ge, g, 0), "instantiate");
int fails = 0;
for (int rep = 0; rep < 6; ++rep) {
const int64_t ctx = 100000 + rep * 4001 + rep % 4;
std::vector<float> sc((size_t) (nq * max_blocks), 0.f), l((size_t) nv * nq);
std::vector<int32_t> steps((size_t) nq * k::kStepCount);
for (int i = 0; i < nq; ++i) {
const int64_t n_kv = ctx - (nq - 1 - i);
int32_t* st = steps.data() + (size_t) i * k::kStepCount;
st[k::kStepPos] = (int32_t) (n_kv - 1);
st[k::kStepNKv] = (int32_t) n_kv;
st[k::kStepNBid] = (int32_t) (n_kv / 4);
st[k::kStepWidth] = (int32_t) k::qsa_selection_width(n_kv, s);
fill_row(sc.data() + (size_t) i * max_blocks, n_kv, n_kv / 4, rep % 2 ? kFewValues : kRandom, rng);
fill_logits(l.data() + (size_t) i * nv, nv, rep % 2 ? aTiesAcross : aRandom, rng);
}
d_sc.put(sc);
d_st.put(steps);
d_l.put(l);
ck(cudaGraphLaunch(ge, cs), "graph launch");
k::qsa_block_topk(d_sc.p, d_st.p, nq, max_blocks, cap, s, d_old.p, cs);
k::SamplerParams sp;
sp.greedy = true;
k::sample_tokens(d_l.p, nq, nv, nullptr, 0, sp, a_old.p, cs);
ck(cudaStreamSynchronize(cs), "graph sync");
const auto x = d_new.get(), y = d_old.get();
const auto u = a_new.get(), v = a_old.get();
int64_t d1 = 0, d2 = 0;
for (size_t i = 0; i < x.size(); ++i) d1 += x[i] != y[i];
for (size_t i = 0; i < u.size(); ++i) d2 += u[i] != v[i];
if (d1 || d2) ++fails;
std::printf("%s graph replay %d (ctx %lld): %lld ids, %lld tokens differ\n", d1 || d2 ? "FAIL" : "ok ", rep,
(long long) ctx, (long long) d1, (long long) d2);
}
cudaGraphExecDestroy(ge);
cudaGraphDestroy(g);
cudaStreamDestroy(cs);
return fails;
}
// back-to-back launches on `cs` (the engine's are too: one stream, inside a graph)
template <typename F> float time_us(cudaStream_t cs, F&& f, int reps = 300) {
cudaEvent_t a, b;
cudaEventCreate(&a);
cudaEventCreate(&b);
for (int i = 0; i < 5; ++i) f();
cudaEventRecord(a, cs);
for (int i = 0; i < reps; ++i) f();
cudaEventRecord(b, cs);
ck(cudaEventSynchronize(b), "time");
float ms = 0.f;
cudaEventElapsedTime(&ms, a, b);
cudaEventDestroy(a);
cudaEventDestroy(b);
return ms * 1000.f / (float) reps;
}
int bench() {
std::mt19937 rng(11);
const k::QsaShapes s = k::qsa_real_shapes();
const int64_t cap = k::qsa_selection_width(k::kTopkMaxCells, s);
cudaStream_t cs;
ck(cudaStreamCreateWithFlags(&cs, cudaStreamNonBlocking), "stream");
std::printf("QSA top-k, per call (us); capacity = context + 272 as the speed runs set --max-context\n");
for (const int64_t ctx : {32768LL, 131072LL, 262144LL - 272}) {
for (const int nq : {1, 5}) {
const int64_t capacity = ctx + 272, max_blocks = capacity / 4 + 2;
std::vector<float> sc((size_t) (nq * max_blocks), 0.f);
std::vector<int32_t> steps((size_t) nq * k::kStepCount);
for (int i = 0; i < nq; ++i) {
const int64_t n_kv = ctx - (nq - 1 - i);
int32_t* st = steps.data() + (size_t) i * k::kStepCount;
st[k::kStepPos] = (int32_t) (n_kv - 1);
st[k::kStepNKv] = (int32_t) n_kv;
st[k::kStepNBid] = (int32_t) (n_kv / 4);
st[k::kStepWidth] = (int32_t) k::qsa_selection_width(n_kv, s);
fill_row(sc.data() + (size_t) i * max_blocks, n_kv, n_kv / 4, kRandom, rng);
}
Dev<float> d_sc(sc.size());
Dev<int32_t> d_st(steps.size()), d_ids((size_t) (nq * cap));
d_sc.put(sc);
d_st.put(steps);
const float* sp = d_sc.p;
const int32_t* st = d_st.p;
int32_t* ids = d_ids.p;
const float t_old = time_us(cs, [&] { k::qsa_block_topk(sp, st, nq, max_blocks, cap, s, ids, cs); });
const float t_ref = time_us(cs, [&] { k::qsa_block_topk_ref(sp, st, nq, max_blocks, cap, s, ids, cs); });
const float t_new = time_us(cs, [&] { k::qsa_block_topk_cluster(sp, st, nq, max_blocks, cap, s, ids, cs); });
std::printf(" ctx %6lld nq %d: one-CTA dispatch %7.1f (ref %7.1f) cluster %6.1f -> %.1fx\n",
(long long) ctx, nq, t_old, t_ref, t_new, t_old / t_new);
}
}
std::printf("argmax, per call (us)\n");
for (const int nv : {248320, 32768}) {
for (const int T : {1, 5}) {
std::vector<float> l((size_t) nv * T);
for (int t = 0; t < T; ++t) fill_logits(l.data() + (size_t) t * nv, nv, aRandom, rng);
Dev<float> d_l(l.size());
Dev<int> d_o((size_t) T);
d_l.put(l);
k::SamplerParams sp;
sp.greedy = true;
const float t_old = time_us(cs, [&] { k::sample_tokens(d_l.p, T, nv, nullptr, 0, sp, d_o.p, cs); });
const float t_new = time_us(cs, [&] { k::sample_greedy_cluster(d_l.p, T, nv, d_o.p, cs); });
std::printf(" n_vocab %6d T %d: one block %6.1f cluster %5.1f -> %.1fx\n", nv, T, t_old, t_new,
t_old / t_new);
}
}
cudaStreamDestroy(cs);
return 0;
}
} // namespace
int main(int argc, char** argv) {
set_env("STRATA_QSA_CLUSTER", "0"); // the dispatchers: the one-CTA kernels (the cluster ones called directly)
set_env("STRATA_ARGMAX_MULTI", "0");
const bool do_bench = argc > 1 && std::strcmp(argv[1], "--bench") == 0;
const bool verbose = argc > 2 && std::strcmp(argv[2], "-v") == 0;
int dev = 0, major = 0;
ck(cudaGetDevice(&dev), "device");
ck(cudaDeviceGetAttribute(&major, cudaDevAttrComputeCapabilityMajor, dev), "cc");
std::mt19937 rng(20261002);
// first, so that both kernels' first calls (their per-device checks and opt-ins) happen inside a stream capture,
// as in the engine's decode graphs
const int gfails = run_graph_case(rng);
if (gfails < 0) {
std::printf("SKIP: no thread-block clusters on this card / build (compute capability %d)\n", major);
return 0;
}
if (do_bench) return bench();
int fails = 0, cases = 0;
// contexts: below the width (identity), at it, just past it, odd tails, the register kernel's range, past it
// (block_topk_kernel), up to 262,144; capacities: tight (the speed runs) and 262,144 (a long --max-context)
const int64_t ctxs[] = {7, 2050, 2051, 2052, 2053, 2054, 2055, 3001, 9000, 32768, 32769, 65538, 100003,
131072, 135170, 135171, 200001, 262142, 262143, 262144};
for (const int64_t ctx : ctxs) {
for (const int64_t capacity : {ctx + 272, (int64_t) 262144 + 272}) {
if (capacity < ctx) continue;
for (int p = 0; p < kPatterns; ++p) {
const int nq = (p % 3 == 0) ? 1 : (p % 3 == 1 ? 5 : 16);
if (nq > ctx) continue;
fails += run_topk_case({ctx, capacity, nq, p}, rng, verbose);
++cases;
}
}
}
std::printf("QSA top-k: %d cases, %d failed\n", cases, fails);
int afails = 0, acases = 0;
for (const int nv : {248320, 248321, 248319, 32771, 24576, 8193, 1000, 33, 1}) {
for (int p = 0; p < aPatterns; ++p) {
for (const int T : {1, 5}) {
afails += run_argmax_case(nv, T, p, rng, verbose);
++acases;
}
}
}
std::printf("argmax: %d cases, %d failed\n", acases, afails);
const int total = fails + afails + gfails;
std::printf("%s\n", total == 0 ? "PASS: the cluster kernels are bitwise identical" : "FAIL");
return total == 0 ? 0 : 1;
}
+17 -1
View File
@@ -26,6 +26,7 @@
#include <cmath>
#include <cstdio>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <random>
@@ -331,7 +332,22 @@ int fused_multi_lds_parity(const float* d_norm, const uint16_t* d_down, const ui
check(cudaMemcpy(s.mixed.data(), d_mixed, s.mixed.size() * sizeof(float), cudaMemcpyDeviceToHost), "multi read mixed");
return s;
};
auto same = [](const Snapshot& a, const Snapshot& b) {
// the split read (STRATA_GR_V3=1) sums in another order than the single-token kernel: equal within float
// rounding, not to the bit, so it is compared with a relative tolerance; the default kernels bit for bit
static const bool v3 = [] { const char* v = std::getenv("STRATA_GR_V3"); return v != nullptr && std::atoi(v) != 0; }();
auto close = [](const std::vector<float>& x, const std::vector<float>& y) {
double worst = 0.0, mag = 1e-30;
for (size_t i = 0; i < x.size(); ++i) {
worst = std::max(worst, (double) std::fabs(x[i] - y[i]));
mag = std::max(mag, (double) std::fabs(y[i]));
}
if (worst > 2e-6 * mag)
std::printf(" tolerance: worst %.3e of max |ref| %.3e (rel %.3e), n %zu\n", worst, mag, worst / mag, x.size());
return worst <= 2e-6 * mag;
};
auto same = [&](const Snapshot& a, const Snapshot& b) {
if (v3) // `lo` is the default kernels' workspace between down and up; the split read keeps it in shared memory
return close(a.r_out, b.r_out) && close(a.rs, b.rs) && close(a.inject, b.inject) && close(a.mixed, b.mixed);
return std::memcmp(a.r_out.data(), b.r_out.data(), a.r_out.size() * sizeof(float)) == 0 &&
std::memcmp(a.lo.data(), b.lo.data(), a.lo.size() * sizeof(float)) == 0 &&
std::memcmp(a.rs.data(), b.rs.data(), a.rs.size() * sizeof(float)) == 0 &&
+57 -1
View File
@@ -467,6 +467,59 @@ int check_q5_1_min(cudaStream_t s) {
"(largest per-block sum shift %.3g) %s\n", e_q, e_s, shift, ok ? "ok" : "FAIL");
return ok ? 0 : 1;
}
// #290: the BF16 token embedding (--embd-gguf) - iq_embed_rows and iq_dequant_f32 on a random BF16 table against
// the exact widening (bits << 16), every bit; rows gathered out of order, with repeats
int check_bf16_embd(cudaStream_t s) {
constexpr int kBf16 = 30;
const int64_t H = 2560, V = 61, NT = 97;
int failures = 0;
if (!strata::kernels::embed_type_supported(kBf16) || strata::kernels::iq_supported(kBf16) ||
strata::kernels::iq_row_bytes(kBf16, H) != (size_t) H * 2) {
std::printf("bf16 embedding: type support / row bytes wrong\n");
return 1;
}
std::mt19937 rng(290);
std::vector<uint16_t> table((size_t) (V * H));
for (auto& v : table) { // any bit pattern but NaN (a NaN's payload is not what the test is about)
do v = (uint16_t) (rng() & 0xffff); while ((v & 0x7f80) == 0x7f80 && (v & 0x7f));
}
std::vector<int32_t> tok((size_t) NT);
for (auto& t : tok) t = (int32_t) (rng() % V);
void* dt = nullptr;
int32_t* dtok = nullptr;
float* dout = nullptr;
cudaMalloc(&dt, table.size() * 2);
cudaMalloc((void**) &dtok, tok.size() * 4);
const int64_t out_rows = NT > V ? NT : V; // the gathered rows (NT) and the whole table (V) share the buffer
cudaMalloc((void**) &dout, (size_t) (out_rows * H) * 4);
cudaMemcpy(dt, table.data(), table.size() * 2, cudaMemcpyHostToDevice);
cudaMemcpy(dtok, tok.data(), tok.size() * 4, cudaMemcpyHostToDevice);
auto widen = [](uint16_t b) { const uint32_t u = (uint32_t) b << 16; float f; std::memcpy(&f, &u, 4); return f; };
std::vector<float> got((size_t) (out_rows * H));
strata::kernels::iq_embed_rows(kBf16, dt, (size_t) H * 2, dtok, NT, H, dout, s);
cudaStreamSynchronize(s);
cudaMemcpy(got.data(), dout, (size_t) (NT * H) * 4, cudaMemcpyDeviceToHost);
size_t rows_differ = 0;
for (int64_t r = 0; r < NT; ++r)
for (int64_t d = 0; d < H; ++d) {
const float want = widen(table[(size_t) (tok[(size_t) r] * H + d)]);
if (std::memcmp(&got[(size_t) (r * H + d)], &want, 4) != 0) { ++rows_differ; break; }
}
strata::kernels::iq_dequant_f32(kBf16, dt, V * H, dout, s);
cudaStreamSynchronize(s);
cudaMemcpy(got.data(), dout, table.size() * 4, cudaMemcpyDeviceToHost);
size_t values_differ = 0;
for (size_t i = 0; i < table.size(); ++i) {
const float want = widen(table[i]);
values_differ += std::memcmp(&got[i], &want, 4) != 0;
}
cudaFree(dt); cudaFree(dtok); cudaFree(dout);
std::printf("bf16 embedding: %lld gathered rows, %zu differ; dequant of %lld values, %zu differ in any bit\n",
(long long) NT, rows_differ, (long long) (V * H), values_differ);
if (rows_differ || values_differ) ++failures;
return failures;
}
} // namespace
int main(int argc, char** argv) {
@@ -474,7 +527,8 @@ int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: native_expert_parity <shard.gguf> [layer ...]\n"
" native_expert_parity --synthetic GU/DOWN ... (ggml type names, e.g. q4_K/q5_1)\n"
" native_expert_parity --q5_1-min\n");
" native_expert_parity --q5_1-min\n"
" native_expert_parity --bf16-embd\n");
return 2;
}
// #152's width check tests the opt-in rule (the multi-token kernels from one token on)
@@ -491,6 +545,8 @@ int main(int argc, char** argv) {
const std::string mode = argv[1];
if (mode == "--q5_1-min") {
failures += check_q5_1_min(s);
} else if (mode == "--bf16-embd") {
failures += check_bf16_embd(s);
} else if (mode == "--synthetic") {
for (int i = 2; i < argc; ++i) {
const std::string arg = argv[i];
+88 -23
View File
@@ -3,10 +3,12 @@
// See include/strata/kernels/ngram.hpp for the semantics, the rival readings, and the note on MADV_RANDOM.
#include "strata/kernels/ngram.hpp"
#include "strata/artifact/gguf_reader.hpp"
#include "strata/artifact/dequant.hpp"
#include "strata/kernels/f16_bits.hpp"
#include "strata/ngram/ple_reader.hpp"
#include <cerrno>
#include <cmath>
#include <cstdio>
#include <cstring>
#if !defined(_WIN32)
@@ -111,11 +113,33 @@ void iq4nl_dequant_row(const uint8_t* row, float* out160) {
}
}
namespace {
struct Fp8Table {
float v[256];
Fp8Table() {
for (int x = 0; x < 256; ++x) {
const int s = x >> 7, e = (x >> 3) & 15, m = x & 7;
float f;
if (e == 15 && m == 7) f = 0.0f; // NaN in e4m3fn; never in a real table, read as 0
else if (e == 0) f = std::ldexp((float) m / 8.0f, -6);
else f = std::ldexp(1.0f + (float) m / 8.0f, e - 7);
v[x] = s ? -f : f;
}
}
};
const Fp8Table kFp8;
} // namespace
void fp8_e4m3_dequant_row(const uint8_t* row, float scale, float* out160) {
for (int j = 0; j < PLE_HEAD_DIM; ++j) out160[j] = kFp8.v[row[j]] * scale;
}
// ---------------------------------------------------------------------------------------------------
struct PleTable::Impl {
GgufFile* file = nullptr;
const uint8_t* data = nullptr;
uint64_t n_rows = 0;
bool q5_0 = false; // #296: Q5_0 rows (110 B), the mapped reader only
mutable uint64_t bytes_read = 0;
// Direct mode (plan v0.3 P2): the mapping above is released after the header parse and every row comes
// from an unbuffered SSD read into `raw`.
@@ -125,7 +149,16 @@ struct PleTable::Impl {
bool pending = false;
bool locked = false;
uint32_t rows[PLE_N_HEADS] = {};
uint8_t raw[PLE_N_HEADS * PLE_ROW_BYTES] = {};
uint8_t raw[PLE_N_HEADS * PLE_ROW_BYTES_MAX] = {};
bool fp8 = false; // F8_E4M3 rows (tools/ple_fp8_pack.py); else IQ4_NL
float scale = 1.0f; // the FP8 table's one scale
uint32_t rb = PLE_ROW_BYTES; // bytes per row
void decode(const uint8_t* row, float* out160) const {
if (fp8) fp8_e4m3_dequant_row(row, scale, out160);
else if (q5_0)
for (int b = 0; b < PLE_HEAD_DIM / 32; ++b) strata::dequantize_q5_0(row + (size_t) b * 22, out160 + b * 32);
else iq4nl_dequant_row(row, out160);
}
};
PleTable::PleTable() : impl_(new Impl) {}
@@ -155,8 +188,31 @@ bool PleTable::open(const std::string& gguf_path, std::string& err, const PleIoO
close();
return false;
}
if (std::strcmp(t->type_name(), "IQ4_NL") != 0) {
err = std::string("per_layer_token_embd.weight is ") + t->type_name() + ", not IQ4_NL";
// IQ4_NL (ISTA-DASLab's shard 2, the original's own GGUF), Q5_0 (#296: OrcaRouter's GGUF), or the FP8 table as
// shipped: I8 bytes marked strata.ple.format = f8_e4m3 with strata.ple.scale (tools/ple_fp8_pack.py)
impl_->fp8 = false;
impl_->q5_0 = std::strcmp(t->type_name(), "Q5_0") == 0;
impl_->rb = impl_->q5_0 ? (PLE_HEAD_DIM / 32) * 22 : PLE_ROW_BYTES;
if (std::strcmp(t->type_name(), "I8") == 0) {
const MetaValue* f = impl_->file->get("strata.ple.format");
const MetaValue* s = impl_->file->get("strata.ple.scale");
if (f == nullptr || f->s != "f8_e4m3" || s == nullptr || !(s->num() > 0.0)) {
err = "per_layer_token_embd.weight is I8 without strata.ple.format = f8_e4m3 and a positive strata.ple.scale";
close();
return false;
}
impl_->fp8 = true;
impl_->scale = (float) s->num();
impl_->rb = PLE_ROW_BYTES_FP8;
} else if (!impl_->q5_0 && std::strcmp(t->type_name(), "IQ4_NL") != 0) {
err = std::string("per_layer_token_embd.weight is ") + t->type_name() + ", not IQ4_NL, Q5_0 or FP8 (I8)";
close();
return false;
}
// The direct reader has fixed 90-byte rows. Keep it for IQ4_NL; Q5_0's
// 110-byte rows use the mapped path until that reader supports variable rows.
if (impl_->q5_0 && io.mode == PleIo::Direct) {
err = "Q5_0 PLE requires --ple-io mmap";
close();
return false;
}
@@ -171,7 +227,7 @@ bool PleTable::open(const std::string& gguf_path, std::string& err, const PleIoO
// assumed. A wrong data offset would leave a different remainder.
// A shard may hold other tensors too (Swift 1.5's shard 1 holds layers 0-12 and the table): the table must
// then fit inside the file at its own offset; alone in its shard (the original's shard 2) it fills it exactly.
const uint64_t need = impl_->n_rows * (uint64_t) PLE_ROW_BYTES;
const uint64_t need = impl_->n_rows * (uint64_t) impl_->rb;
const uint64_t have = impl_->file->file_size() - impl_->file->data_start();
const bool alone = impl_->file->tensors().size() == 1;
if (alone ? need != have : t->offset + need > have) {
@@ -179,7 +235,7 @@ bool PleTable::open(const std::string& gguf_path, std::string& err, const PleIoO
std::snprintf(buf, sizeof buf,
"PLE table size mismatch: %llu rows x %d B = %llu at offset %llu, but the file holds %llu from "
"data_start %llu",
(unsigned long long) impl_->n_rows, PLE_ROW_BYTES, (unsigned long long) need,
(unsigned long long) impl_->n_rows, (int) impl_->rb, (unsigned long long) need,
(unsigned long long) t->offset, (unsigned long long) have,
(unsigned long long) impl_->file->data_start());
err = buf;
@@ -194,10 +250,12 @@ bool PleTable::open(const std::string& gguf_path, std::string& err, const PleIoO
delete impl_->file;
impl_->file = nullptr;
impl_->data = nullptr;
if (!impl_->reader.open(gguf_path, table_offset, n_rows, io.max_inflight, io.cache_rows, err, io.io_thread)) {
if (!impl_->reader.open(gguf_path, table_offset, n_rows, io.max_inflight, io.cache_rows, err, io.io_thread,
impl_->rb)) {
close();
return false;
}
impl_->reader.set_keepalive(io.keepalive_ms, io.keepalive_window_s);
impl_->n_rows = n_rows;
}
if (io.mode == PleIo::Mmap && io.lock && impl_->data != nullptr) {
@@ -230,33 +288,37 @@ void PleTable::close() {
impl_->file = nullptr;
impl_->data = nullptr;
impl_->n_rows = 0;
impl_->rb = PLE_ROW_BYTES;
impl_->q5_0 = false;
impl_->fp8 = false;
}
bool PleTable::is_open() const { return impl_->data != nullptr || impl_->reader.is_open(); }
bool PleTable::locked() const { return impl_->locked; }
const char* PleTable::format() const { return impl_->fp8 ? "F8_E4M3" : impl_->q5_0 ? "Q5_0" : "IQ4_NL"; }
PleIo PleTable::mode() const { return impl_->mode; }
uint64_t PleTable::rows() const { return impl_->n_rows; }
uint64_t PleTable::bytes_read() const { return impl_->bytes_read; }
void PleTable::read_row(uint32_t row, float* out160) const {
if (impl_->mode == PleIo::Direct && impl_->reader.is_open()) {
uint8_t raw[PLE_ROW_BYTES];
uint8_t raw[PLE_ROW_BYTES_MAX];
std::string err;
const auto t = impl_->reader.issue(&row, 1, raw);
if (!impl_->reader.collect(t, err)) {
std::memset(out160, 0, (size_t) PLE_HEAD_DIM * sizeof(float));
return;
}
iq4nl_dequant_row(raw, out160);
impl_->bytes_read += PLE_ROW_BYTES;
impl_->decode(raw, out160);
impl_->bytes_read += impl_->rb;
return;
}
if (impl_->data == nullptr || row >= impl_->n_rows) {
std::memset(out160, 0, (size_t) PLE_HEAD_DIM * sizeof(float));
return;
}
iq4nl_dequant_row(impl_->data + (size_t) row * PLE_ROW_BYTES, out160);
impl_->bytes_read += PLE_ROW_BYTES;
impl_->decode(impl_->data + (size_t) row * impl_->rb, out160);
impl_->bytes_read += impl_->rb;
}
bool PleTable::issue(const uint32_t* rows16) {
@@ -273,8 +335,8 @@ bool PleTable::issue(const uint32_t* rows16) {
ULONG_PTR n = 0;
for (int h = 0; h < PLE_N_HEADS; ++h) {
if (rows16[h] >= impl_->n_rows) continue;
ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * PLE_ROW_BYTES);
ranges[n].NumberOfBytes = PLE_ROW_BYTES;
ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * impl_->rb);
ranges[n].NumberOfBytes = impl_->rb;
++n;
}
if (n > 0) (void) PrefetchVirtualMemory(GetCurrentProcess(), n, ranges, 0);
@@ -290,8 +352,8 @@ bool PleTable::collect(float* out2560, std::string& err) {
if (impl_->mode == PleIo::Direct) {
if (!impl_->reader.collect(impl_->ticket, err)) return false;
for (int h = 0; h < PLE_N_HEADS; ++h)
iq4nl_dequant_row(impl_->raw + (size_t) h * PLE_ROW_BYTES, out2560 + (size_t) h * PLE_HEAD_DIM);
impl_->bytes_read += (uint64_t) PLE_N_HEADS * PLE_ROW_BYTES;
impl_->decode(impl_->raw + (size_t) h * impl_->rb, out2560 + (size_t) h * PLE_HEAD_DIM);
impl_->bytes_read += (uint64_t) PLE_N_HEADS * impl_->rb;
return true;
}
for (int h = 0; h < PLE_N_HEADS; ++h) read_row(impl_->rows[h], out2560 + (size_t) h * PLE_HEAD_DIM);
@@ -302,11 +364,11 @@ bool PleTable::gather_batch(const uint32_t* rows, size_t n_tokens, float* out, s
if (impl_->pending) { err = "PleTable::gather_batch while a token is in flight"; return false; }
const size_t n = n_tokens * (size_t) PLE_N_HEADS;
if (impl_->mode == PleIo::Direct) {
std::vector<uint8_t> raw(n * PLE_ROW_BYTES);
std::vector<uint8_t> raw(n * impl_->rb);
const auto ticket = impl_->reader.issue(rows, n, raw.data());
if (!impl_->reader.collect(ticket, err)) return false;
for (size_t i = 0; i < n; ++i) iq4nl_dequant_row(raw.data() + i * PLE_ROW_BYTES, out + i * PLE_HEAD_DIM);
impl_->bytes_read += (uint64_t) n * PLE_ROW_BYTES;
for (size_t i = 0; i < n; ++i) impl_->decode(raw.data() + i * impl_->rb, out + i * PLE_HEAD_DIM);
impl_->bytes_read += (uint64_t) n * impl_->rb;
return true;
}
for (size_t i = 0; i < n; ++i) read_row(rows[i], out + i * PLE_HEAD_DIM);
@@ -317,15 +379,18 @@ void PleTable::set_injected_delay_us(double us) { impl_->reader.set_injected_del
std::string PleTable::io_report() const {
if (impl_->mode != PleIo::Direct || !impl_->reader.is_open()) return {};
const strata::ngram::ReaderStats& s = impl_->reader.stats();
char buf[320];
std::snprintf(buf, sizeof buf,
const strata::ngram::ReaderStats s = impl_->reader.snapshot();
char buf[400];
int n = std::snprintf(buf, sizeof buf,
"ple io: %llu rows, %.1f%% row-cache hits, %llu SSD reads (%.1f MB), read p50 %.0f us p99 %.0f us, "
"blocked %.3f ms total (submit %.3f ms), cache %llu/%llu rows",
(unsigned long long) s.requests, s.requests ? 100.0 * (double) s.cache_hits / (double) s.requests : 0.0,
(unsigned long long) s.reads, (double) s.bytes / 1e6, s.percentile(0.5), s.percentile(0.99),
s.wait_us / 1000.0, s.submit_us / 1000.0, (unsigned long long) impl_->reader.cache_size(),
(unsigned long long) impl_->reader.cache_capacity());
if (s.keepalive_reads > 0 && n > 0 && n < (int) sizeof buf)
std::snprintf(buf + n, sizeof buf - (size_t) n, ", SSD kept awake by %llu reads (slowest %.1f ms)",
(unsigned long long) s.keepalive_reads, s.keepalive_us_max / 1000.0);
return buf;
}
@@ -360,8 +425,8 @@ void PleTable::gather(const uint32_t* rows16, float* out2560) const {
for (int h = 0; h < PLE_N_HEADS; ++h) {
// Out-of-range rows are handled by `read_row` as zeros and have no address to prefetch.
if (rows16[h] >= impl_->n_rows) continue;
ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * PLE_ROW_BYTES);
ranges[n].NumberOfBytes = PLE_ROW_BYTES;
ranges[n].VirtualAddress = (PVOID) (impl_->data + (size_t) rows16[h] * impl_->rb);
ranges[n].NumberOfBytes = impl_->rb;
++n;
}
if (n > 0) (void) PrefetchVirtualMemory(GetCurrentProcess(), n, ranges, 0);
+84
View File
@@ -0,0 +1,84 @@
// src/kernels/ple_fp8_parity.cpp - the FP8 PLE table (tools/ple_fp8_pack.py) read through PleTable, against values
// decoded from the checkpoint itself, and against the IQ4_NL table it replaces.
//
// ple_fp8_parity <ple-fp8.gguf> <reference.bin> [<iq4nl shard.gguf>]
//
// reference.bin: int32 n, uint32 rows[n], float32 values[n][160] - the rows as torch decodes the checkpoint's
// F8_E4M3 bytes, times its weight_scale (n a multiple of 16 for the gather_batch check). Every row must match BIT FOR BIT through
// both I/O modes (Direct and Mmap) and through the prompt path's gather_batch. With the IQ4_NL shard the same rows
// are compared too: same table, so a correlation near 0.997 - a shifted row order would be ~0.
#include "strata/kernels/ngram.hpp"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
namespace k = strata::kernels;
int main(int argc, char** argv) {
if (argc < 3) {
std::fprintf(stderr, "usage: ple_fp8_parity <ple-fp8.gguf> <reference.bin> [<iq4nl shard.gguf>]\n");
return 2;
}
std::ifstream f(argv[2], std::ios::binary);
int32_t n = 0;
f.read((char*) &n, 4);
std::vector<uint32_t> rows((size_t) n);
std::vector<float> ref((size_t) n * k::PLE_HEAD_DIM);
f.read((char*) rows.data(), (std::streamsize) rows.size() * 4);
f.read((char*) ref.data(), (std::streamsize) ref.size() * 4);
if (!f || n <= 0) { std::fprintf(stderr, "cannot read %s\n", argv[2]); return 2; }
int bad = 0;
for (const k::PleIo mode : {k::PleIo::Direct, k::PleIo::Mmap}) {
k::PleTable t;
std::string err;
k::PleIoOptions io;
io.mode = mode;
if (!t.open(argv[1], err, io)) { std::fprintf(stderr, "open: %s\n", err.c_str()); return 1; }
std::vector<float> got((size_t) n * k::PLE_HEAD_DIM);
for (int i = 0; i < n; ++i) t.read_row(rows[(size_t) i], got.data() + (size_t) i * k::PLE_HEAD_DIM);
int diff = 0;
for (size_t j = 0; j < got.size(); ++j) diff += std::memcmp(&got[j], &ref[j], 4) != 0;
std::vector<float> batch((size_t) n * k::PLE_HEAD_DIM); // the prompt path: all rows as one request
const bool ok = t.gather_batch(rows.data(), (size_t) n / k::PLE_N_HEADS, batch.data(), err);
int bdiff = 0;
const size_t nb = (size_t) n / k::PLE_N_HEADS * k::PLE_N_HEADS * k::PLE_HEAD_DIM;
for (size_t j = 0; ok && j < nb; ++j) bdiff += std::memcmp(&batch[j], &ref[j], 4) != 0;
std::printf("%-6s %s, %llu rows: read_row %d/%zu values differ, gather_batch %s %d differ\n",
mode == k::PleIo::Direct ? "Direct" : "Mmap", t.format(), (unsigned long long) t.rows(), diff,
got.size(), ok ? "ok," : "FAILED,", bdiff);
bad += diff + bdiff + (ok ? 0 : 1) + (std::strcmp(t.format(), "F8_E4M3") != 0);
}
if (argc > 3) {
k::PleTable q;
std::string err;
if (!q.open(argv[3], err)) { std::fprintf(stderr, "open %s: %s\n", argv[3], err.c_str()); return 1; }
double cmin = 1, rsum = 0;
int counted = 0;
std::vector<float> v(k::PLE_HEAD_DIM);
for (int i = 0; i < n; ++i) {
q.read_row(rows[(size_t) i], v.data());
const float* r = ref.data() + (size_t) i * k::PLE_HEAD_DIM;
double sa = 0, sb = 0, sab = 0, sa2 = 0, sb2 = 0, e2 = 0, r2 = 0;
for (int j = 0; j < k::PLE_HEAD_DIM; ++j) {
sa += r[j]; sb += v[j]; sab += (double) r[j] * v[j]; sa2 += (double) r[j] * r[j]; sb2 += (double) v[j] * v[j];
e2 += ((double) v[j] - r[j]) * ((double) v[j] - r[j]); r2 += (double) r[j] * r[j];
}
if (r2 == 0) continue; // an all-zero row (the table has some)
const double m = k::PLE_HEAD_DIM;
const double c = (sab - sa * sb / m) / std::sqrt((sa2 - sa * sa / m) * (sb2 - sb * sb / m));
cmin = std::fmin(cmin, c);
rsum += std::sqrt(e2 / r2);
++counted;
}
std::printf("IQ4_NL table, same rows: correlation min %.4f, error vs FP8 %.1f%% mean (%d non-zero rows)\n",
cmin, 100 * rsum / (counted ? counted : 1), counted);
if (cmin < 0.98) ++bad;
}
std::printf("RESULT: %s\n", bad ? "MISMATCH" : "FP8 rows exact");
return bad ? 1 : 0;
}
+59
View File
@@ -0,0 +1,59 @@
// Real-artifact Q5_0 PLE rows against ggml's reference dequantizer.
#define NOMINMAX
#include "strata/artifact/gguf_reader.hpp"
#include "strata/kernels/ngram.hpp"
#include "ggml.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
#include <vector>
int main(int argc, char** argv) {
if (argc != 2) {
std::fprintf(stderr, "usage: ple_q5_parity <gguf-containing-ple>\n");
return 2;
}
strata::GgufFile gguf(argv[1]);
const auto* tensor = gguf.find("per_layer_token_embd.weight");
if (!tensor || tensor->type != 6 || tensor->shape.size() != 2 || tensor->shape[0] != 160) {
std::fprintf(stderr, "expected Q5_0 PLE [160, N]\n");
return 2;
}
strata::kernels::PleIoOptions options;
options.mode = strata::kernels::PleIo::Mmap;
strata::kernels::PleTable table;
std::string err;
if (!table.open(argv[1], err, options)) {
std::fprintf(stderr, "%s\n", err.c_str());
return 1;
}
const auto* traits = ggml_get_type_traits(GGML_TYPE_Q5_0);
const uint8_t* bytes = gguf.tensor_data(*tensor);
const uint32_t probes[] = {0, 1, 12345, 20000003, (uint32_t) (table.rows() - 1)};
double max_abs = 0.0;
for (uint32_t row : probes) {
float got[160], want[160];
table.read_row(row, got);
traits->to_float(bytes + (size_t) row * 110, want, 160);
for (int i = 0; i < 160; ++i)
max_abs = std::max(max_abs, (double) std::fabs(got[i] - want[i]));
}
uint32_t rows[16];
for (int i = 0; i < 16; ++i) rows[i] = probes[i % 5];
float batch[16 * 160];
if (!table.issue(rows) || !table.collect(batch, err)) {
std::fprintf(stderr, "collect: %s\n", err.c_str());
return 1;
}
for (int h = 0; h < 16; ++h) {
float want[160];
traits->to_float(bytes + (size_t) rows[h] * 110, want, 160);
for (int i = 0; i < 160; ++i)
max_abs = std::max(max_abs, (double) std::fabs(batch[h * 160 + i] - want[i]));
}
std::printf("Q5_0 PLE: %zu rows, max_abs %.3e %s\n", (size_t) table.rows(), max_abs,
max_abs <= 1e-6 ? "PASS" : "FAIL");
return max_abs <= 1e-6 ? 0 : 1;
}
+62
View File
@@ -0,0 +1,62 @@
// Q5_1 shared-expert projection: Strata CUDA dequantization vs ggml.
#define NOMINMAX
#include "strata/artifact/gguf_reader.hpp"
#include "strata/kernels/dequant_bf16.hpp"
#include "ggml.h"
#include <cuda_runtime.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdint>
#include <cstring>
#include <vector>
int main(int argc, char** argv) {
if (argc != 2) {
std::fprintf(stderr, "usage: q5_projection_parity <shard1.gguf>\n");
return 2;
}
strata::GgufFile gguf(argv[1]);
const auto* t = gguf.find("blk.0.ffn_down_shexp.weight");
if (!t || t->type != 7 || t->shape.size() != 2) {
std::fprintf(stderr, "expected blk.0.ffn_down_shexp.weight Q5_1\n");
return 2;
}
const int64_t cols = (int64_t) t->shape[0], rows = std::min<int64_t>(4, (int64_t) t->shape[1]);
const size_t weight_bytes = (size_t) rows * (size_t) (cols / 32) * 24;
const size_t elements = (size_t) rows * (size_t) cols;
const uint8_t* source = gguf.tensor_data(*t);
void *dweight = nullptr, *dfloat = nullptr, *dbf16 = nullptr;
if (cudaMalloc(&dweight, weight_bytes) != cudaSuccess ||
cudaMalloc(&dfloat, elements * sizeof(float)) != cudaSuccess ||
cudaMalloc(&dbf16, elements * sizeof(uint16_t)) != cudaSuccess) {
std::fprintf(stderr, "CUDA allocation failed\n");
return 1;
}
cudaMemcpy(dweight, source, weight_bytes, cudaMemcpyHostToDevice);
strata::kernels::dequant_f32(7, dweight, 0, rows, cols, (float*) dfloat, nullptr);
strata::kernels::dequant_bf16(7, dweight, 0, rows, cols, (uint16_t*) dbf16, nullptr);
std::vector<float> got(elements), ref(elements);
std::vector<uint16_t> got_bf16(elements);
cudaMemcpy(got.data(), dfloat, elements * sizeof(float), cudaMemcpyDeviceToHost);
cudaMemcpy(got_bf16.data(), dbf16, elements * sizeof(uint16_t), cudaMemcpyDeviceToHost);
cudaFree(dweight); cudaFree(dfloat); cudaFree(dbf16);
const auto* traits = ggml_get_type_traits(GGML_TYPE_Q5_1);
for (int64_t r = 0; r < rows; ++r)
traits->to_float(source + (size_t) r * (size_t) (cols / 32) * 24,
ref.data() + (size_t) r * (size_t) cols, cols);
double max_abs = 0;
size_t bf16_bad = 0;
for (size_t i = 0; i < elements; ++i) {
max_abs = std::max(max_abs, (double) std::fabs(got[i] - ref[i]));
uint32_t bits;
std::memcpy(&bits, &ref[i], sizeof(bits));
const uint16_t expected = (uint16_t) ((bits + 0x7fffu + ((bits >> 16) & 1u)) >> 16);
if (got_bf16[i] != expected) ++bf16_bad;
}
const bool ok = max_abs <= 1e-6 && bf16_bad == 0;
std::printf("Q5_1 projection: %zu elements, max_abs %.3e, bf16 differences %zu %s\n",
elements, max_abs, bf16_bad, ok ? "PASS" : "FAIL");
return ok ? 0 : 1;
}
+22 -1
View File
@@ -7,6 +7,7 @@
// 1. against FP64, the new kernel's error is no larger than a small multiple of the old kernel's (both FP32 math);
// 2. the new and old outputs agree to a relative 1e-4 of the output scale;
// then times both over a prompt chunk (the old one in batches of 32, as prefill.cpp calls it).
// HIP builds (S6): the same checks for the RDNA4 matrix-core kernel (STRATA_HIP_WMMA), skipped (77) off gfx12.
// Usage: qsa_prompt_attn_parity [context=32768] [queries=2048] [reps=5]
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/qsa_decode_attn.hpp"
@@ -19,6 +20,7 @@
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <random>
#include <vector>
@@ -196,12 +198,31 @@ int run(int fmt, int64_t ctx, int64_t nq, int reps) { // fmt 1 int8, 0 fp16
} // namespace
int main(int argc, char** argv) {
#if defined(__HIP_PLATFORM_AMD__)
// S6: on AMD the kernel under test is the RDNA4 matrix-core one (opt-in in the engine); other cards skip
{
int dev = 0;
hipDeviceProp_t prop{};
if (hipGetDevice(&dev) != hipSuccess || hipGetDeviceProperties(&prop, dev) != hipSuccess) return 2;
if (std::strncmp(prop.gcnArchName, "gfx12", 5) != 0) {
std::printf("SKIP: %s is not gfx12 (the matrix-core prompt attention is RDNA4 only)\n", prop.gcnArchName);
return 77;
}
#if defined(_WIN32)
_putenv_s("STRATA_HIP_WMMA", "1");
#else
setenv("STRATA_HIP_WMMA", "1", 1);
#endif
}
#endif
const int64_t ctx = argc > 1 ? std::atoll(argv[1]) : 32768;
const int64_t nq = argc > 2 ? std::atoll(argv[2]) : 2048;
const int reps = argc > 3 ? std::atoi(argv[3]) : 5;
int fails = 0;
fails += run(1, ctx, nq, reps);
fails += run(0, ctx, nq, reps);
#if !defined(__HIP_PLATFORM_AMD__)
fails += run(0, ctx, nq, reps); // FP16 KV: the RDNA4 kernel takes int8 KV only
#endif
fails += run(1, 1500, std::min<int64_t>(nq, 1500), reps); // short context: the selection is every cell
fails += run(1, 2100, std::min<int64_t>(nq, 256), reps); // the identity-to-sparse edge
std::printf("FAILURES: %d\n", fails);
+43 -8
View File
@@ -1,7 +1,9 @@
// src/kernels/qsa_select_bench.cpp - the prompt path's QSA selection (qsa_select.hpp) timed per stage, block scores
// (the warp kernel and the tensor-core one) and top-k, for a batch of consecutive queries at a given context, and
// the two scorers compared: score difference and how many selections differ (GPU, synthetic, no model).
// Usage: qsa_select_bench [context=131072] [queries=256] [reps=10]
// Usage: qsa_select_bench [context=131072] [queries=256] [reps=10] [capacity_cells]
// capacity_cells (the engine's --max-context): the score buffers and the top-k dispatch follow the CAPACITY
// (max_blocks = capacity / 4 + 2), the work follows the context. Default: capacity = context (max_blocks = ctx / 4 + 1).
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/qsa_select.hpp"
@@ -33,7 +35,8 @@ int main(int argc, char** argv) {
const int64_t nq = argc > 2 ? std::atoll(argv[2]) : 256;
const int reps = argc > 3 ? std::atoi(argv[3]) : 10;
const k::QsaShapes s = k::qsa_real_shapes();
const int64_t max_blocks = ctx / 4 + 1, cap = k::qsa_selection_width(k::kTopkMaxCells, s);
const int64_t capacity = argc > 4 ? std::atoll(argv[4]) : 0;
const int64_t max_blocks = capacity > 0 ? capacity / 4 + 2 : ctx / 4 + 1, cap = k::qsa_selection_width(k::kTopkMaxCells, s);
std::mt19937 rng(7);
std::normal_distribution<float> nd(0.f, 1.f);
// keys with a shared direction plus noise, so the scores have a spread like a real indexer's
@@ -67,6 +70,8 @@ int main(int argc, char** argv) {
ck(cudaMalloc(&ids_new, (size_t) (nq * cap) * 4), "malloc");
const int64_t active = steps[(size_t) ((nq - 1) * k::kStepCount + k::kStepNBid)] + 1;
auto run_old = [&] { k::qsa_block_scores(d_pooled, d_dead, d_q, d_steps, nq, max_blocks, s, sc_old, nullptr, active); };
// a device without the tensor-core scorer (HIP other than gfx12): time the warp scorer and the top-k only
const bool have_tc = k::qsa_block_scores_tc(d_pooled, d_dead, d_q, d_steps, 1, max_blocks, s, sc_new, nullptr, active);
auto run_new = [&] {
if (!k::qsa_block_scores_tc(d_pooled, d_dead, d_q, d_steps, nq, max_blocks, s, sc_new, nullptr, active)) {
std::fprintf(stderr, "tc scorer refused\n");
@@ -74,12 +79,13 @@ int main(int argc, char** argv) {
}
};
run_old();
run_new();
if (have_tc) run_new();
else ck(cudaMemcpy(sc_new, sc_old, (size_t) (nq * max_blocks) * 4, cudaMemcpyDeviceToDevice), "copy");
k::qsa_block_topk_ref(sc_old, d_steps, nq, max_blocks, cap, s, ids_old, nullptr);
k::qsa_block_topk(sc_new, d_steps, nq, max_blocks, cap, s, ids_new, nullptr);
k::qsa_block_topk(sc_new, d_steps, nq, max_blocks, cap, s, ids_new, nullptr, active);
int32_t* ids_reg = nullptr; // the register top-k on the OLD scores: must equal the reference exactly
ck(cudaMalloc(&ids_reg, (size_t) (nq * cap) * 4), "malloc");
k::qsa_block_topk(sc_old, d_steps, nq, max_blocks, cap, s, ids_reg, nullptr);
k::qsa_block_topk(sc_old, d_steps, nq, max_blocks, cap, s, ids_reg, nullptr, active);
ck(cudaDeviceSynchronize(), "warm");
// compare
std::vector<float> a((size_t) (nq * max_blocks)), b(a.size());
@@ -114,6 +120,32 @@ int main(int argc, char** argv) {
cells_diff += (int64_t) d.size() / 2;
cells_all += w;
}
// accuracy against an FP64 host reference on a sample (blocks below n_bid; the tail block is the warp kernel's own
// arithmetic in both scorers). Gate, as the prompt-attention harness's: the scorer under test is no worse than 4x
// the warp kernel's error, floored at 1e-6 of the score scale.
double err_old = 0, err_new = 0, scale = 0;
{
std::mt19937 srng(11);
const int64_t nqs = std::min<int64_t>(nq, 32);
for (int64_t qs = 0; qs < nqs; ++qs) {
const int64_t i = qs * nq / nqs;
const int64_t nbid = steps[(size_t) (i * k::kStepCount + k::kStepNBid)];
if (nbid <= 0) continue;
for (int sidx = 0; sidx < 1024; ++sidx) {
const int64_t j = sidx < 64 ? std::min<int64_t>(nbid - 1, sidx) : (int64_t) (srng() % (uint64_t) nbid);
double ref = 0;
for (int h = 0; h < 4; ++h) {
double d = 0;
for (int c = 0; c < 128; ++c) d += (double) q[(size_t) (i * 512 + h * 128 + c)] * (double) pooled[(size_t) (j * 128 + c)];
ref += d > 0 ? d : 0;
}
scale = std::max(scale, std::fabs(ref));
err_old = std::max(err_old, std::fabs(ref - (double) a[(size_t) (i * max_blocks + j)]));
err_new = std::max(err_new, std::fabs(ref - (double) b[(size_t) (i * max_blocks + j)]));
}
}
}
const bool acc_ok = !have_tc || err_new <= std::max(4.0 * err_old, 1e-6 * scale);
// time
cudaEvent_t e0, e1;
cudaEventCreate(&e0); cudaEventCreate(&e1);
@@ -126,14 +158,17 @@ int main(int argc, char** argv) {
cudaEventElapsedTime(&ms, e0, e1);
return ms / reps;
};
const float t_old = timed(run_old), t_new = timed(run_new);
const float t_old = timed(run_old), t_new = have_tc ? timed(run_new) : t_old;
const float t_tk = timed([&] { k::qsa_block_topk_ref(sc_old, d_steps, nq, max_blocks, cap, s, ids_old, nullptr); });
const float t_tk2 = timed([&] { k::qsa_block_topk(sc_old, d_steps, nq, max_blocks, cap, s, ids_reg, nullptr); });
const float t_tk2 = timed([&] { k::qsa_block_topk(sc_old, d_steps, nq, max_blocks, cap, s, ids_reg, nullptr, active); });
std::printf("top-k %.3f -> %.3f ms (%.1fx), register top-k identical to the reference %lld/%lld\n", t_tk, t_tk2,
t_tk / t_tk2, (long long) reg_same, (long long) nq);
std::printf("%s accuracy vs FP64 (score scale %.3g): warp kernel max err %.3g, tensor-core max err %.3g (%.2g of scale)\n",
!have_tc ? "SKIP" : acc_ok ? "PASS" : "FAIL", scale, err_old, err_new, scale > 0 ? err_new / scale : 0.0);
if (!have_tc) std::printf("tensor-core scorer not available on this device: warp scorer %.3f ms, top-k %.3f ms (%.0f%% of the two)\n", t_old, t_tk2, 100.0 * t_tk2 / (t_old + t_tk2));
std::printf("ctx %lld, %lld queries x %lld blocks: scores %.3f -> %.3f ms (%.1fx), top-k %.3f ms; score rel diff "
"mean %.2g max %.2g; selections identical %lld/%lld, cells differing %.4f%%\n", (long long) ctx,
(long long) nq, (long long) active, t_old, t_new, t_old / t_new, t_tk, sum_rel / std::max(1.0, n_rel),
max_rel, (long long) same_sel, (long long) nq, cells_all ? 100.0 * (double) cells_diff / (double) cells_all : 0.0);
return 0;
return acc_ok && reg_same == nq ? 0 : 1;
}
+114 -21
View File
@@ -2,6 +2,7 @@
#include "strata/ngram/ple_reader.hpp"
#include <algorithm>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <deque>
@@ -33,15 +34,17 @@ constexpr uint32_t EMPTY = 0xFFFFFFFFu;
/// Set-associative row cache: 8 ways per set, round-robin replacement inside a set. Bounded by construction.
struct RowCache {
uint64_t sets = 0;
uint32_t rb = ROW_BYTES; // bytes per row
std::vector<uint32_t> keys; // sets * WAYS
std::vector<uint8_t> data; // sets * WAYS * ROW_BYTES
std::vector<uint8_t> data; // sets * WAYS * rb
std::vector<uint8_t> next; // per-set replacement pointer
uint64_t used = 0;
void init(uint64_t rows) {
void init(uint64_t rows, uint32_t row_bytes = ROW_BYTES) {
sets = rows / WAYS;
rb = row_bytes;
keys.assign(sets * WAYS, EMPTY);
data.assign(sets * WAYS * ROW_BYTES, 0);
data.assign(sets * WAYS * rb, 0);
next.assign(sets, 0);
used = 0;
}
@@ -53,7 +56,7 @@ struct RowCache {
if (sets == 0) return nullptr;
const uint64_t s = mix(row) % sets;
for (uint32_t w = 0; w < WAYS; ++w)
if (keys[s * WAYS + w] == row) return &data[(s * WAYS + w) * ROW_BYTES];
if (keys[s * WAYS + w] == row) return &data[(s * WAYS + w) * rb];
return nullptr;
}
void insert(uint32_t row, const uint8_t* bytes) {
@@ -63,7 +66,7 @@ struct RowCache {
next[s] = (uint8_t) ((w + 1) % WAYS);
if (keys[s * WAYS + w] == EMPTY) ++used;
keys[s * WAYS + w] = row;
std::memcpy(&data[(s * WAYS + w) * ROW_BYTES], bytes, ROW_BYTES);
std::memcpy(&data[(s * WAYS + w) * rb], bytes, rb);
}
};
@@ -79,6 +82,7 @@ struct Job {
uint32_t ticket = 0;
std::vector<Use> uses;
double issued_us = 0;
bool keepalive = false;// no rows and no ticket: it only keeps the SSD awake (`set_keepalive`)
};
struct TicketState {
@@ -95,6 +99,7 @@ struct PleReader::Impl {
DirectFile file;
uint64_t table_offset = 0;
uint64_t n_rows = 0;
uint32_t row_bytes = ROW_BYTES;
uint32_t max_inflight = 0;
uint8_t* slab = nullptr; // max_inflight slots of 2 pages
std::vector<uint32_t> free_slots;
@@ -108,6 +113,12 @@ struct PleReader::Impl {
size_t ring_pos = 0;
double delay_us = 0;
std::string error;
// the keep-alive (`set_keepalive`), steady-clock microseconds; `keep_us` 0 = off
double keep_us = 0;
double keep_window_us = 0;
double last_issue_us = 0; // the last `issue`
double last_read_us = 0; // the last read that went out, rows or keep-alive
uint64_t rng = 0x9E3779B97F4A7C15ull;
bool threaded = false;
bool stop = false;
@@ -141,7 +152,14 @@ struct PleReader::Impl {
queue.pop_front();
Job& j = inflight[s];
j.issued_us = now_us();
if (!file.submit(j.offset, slot_buf(s), j.length, s, error)) {
std::string kerr; // a keep-alive read that cannot go out must not fail the reader
if (!file.submit(j.offset, slot_buf(s), j.length, s, j.keepalive ? kerr : error)) {
if (j.keepalive) { // no more of them: the SSD may sleep as before
keep_us = 0;
j.keepalive = false;
free_slots.push_back(s);
continue;
}
j.uses.clear();
auto it = tickets.find(j.ticket);
if (it != tickets.end() && it->second.pending > 0) --it->second.pending;
@@ -149,12 +167,35 @@ struct PleReader::Impl {
cancel_queued();
return false;
}
stats.submit_us += now_us() - j.issued_us;
++stats.reads;
last_read_us = j.issued_us;
if (!j.keepalive) { // a keep-alive read counts when it completes (`finish`)
stats.submit_us += now_us() - j.issued_us;
++stats.reads;
}
}
return true;
}
/// When the next keep-alive read is due, or < 0 when none is: it is off, the reader failed, or no rows
/// were asked for within the window (then the SSD may sleep; the next `issue` re-arms it).
double keepalive_due(double now) const {
if (keep_us <= 0 || !error.empty() || last_issue_us <= 0 || now - last_issue_us > keep_window_us) return -1;
return last_read_us + keep_us;
}
/// One page of the table, a different one each time, so the SSD really reads (not its controller's buffer).
void queue_keepalive() {
rng ^= rng << 13;
rng ^= rng >> 7;
rng ^= rng << 17;
const uint64_t first = table_offset / PAGE, end = (table_offset + n_rows * (uint64_t) row_bytes) / PAGE;
Job j;
j.offset = (first + (end > first ? rng % (end - first) : 0)) * PAGE;
j.length = PAGE;
j.keepalive = true;
queue.push_back(std::move(j));
}
bool finish(const Completion& c) {
const uint32_t s = (uint32_t) c.tag;
if (s >= inflight.size()) {
@@ -163,6 +204,17 @@ struct PleReader::Impl {
return false;
}
Job& j = inflight[s];
if (j.keepalive) { // no rows: it counts once it is done, with how long it took;
if (c.ok) { // a failed one turns the keep-alive off, not the reader
++stats.keepalive_reads;
stats.keepalive_us_max = std::max(stats.keepalive_us_max, now_us() - j.issued_us);
} else {
keep_us = 0;
}
j.keepalive = false;
free_slots.push_back(s);
return error.empty() ? pump() : true;
}
if (!c.ok) {
error = "PleReader: a table read failed";
cancel_queued();
@@ -176,7 +228,7 @@ struct PleReader::Impl {
stats.bytes += c.bytes;
const uint8_t* buf = slot_buf(s);
for (const Use& u : j.uses) {
if (u.in_page + ROW_BYTES > c.bytes) {
if (u.in_page + row_bytes > c.bytes) {
error = "PleReader: short read inside the table";
cancel_queued();
j.uses.clear();
@@ -187,7 +239,7 @@ struct PleReader::Impl {
}
}
for (const Use& u : j.uses) {
std::memcpy(u.dst, buf + u.in_page, ROW_BYTES);
std::memcpy(u.dst, buf + u.in_page, row_bytes);
cache.insert(u.row, buf + u.in_page);
}
auto it = tickets.find(j.ticket);
@@ -246,7 +298,14 @@ struct PleReader::Impl {
void worker_loop() {
std::unique_lock<std::mutex> lk(mu);
for (;;) {
cv_work.wait(lk, [&] { return stop || !queue.empty() || busy(); });
// Idle: wait for work. While rows are being asked for, the wait ends in time for a keep-alive read.
while (!(stop || !queue.empty() || busy())) {
const double now = now_us();
const double due = keepalive_due(now);
if (due < 0) cv_work.wait(lk);
else if (now >= due) queue_keepalive();
else cv_work.wait_for(lk, std::chrono::microseconds(std::max<int64_t>(1000, (int64_t) (due - now))));
}
if (stop && !busy()) break;
if (error.empty() && !pump() && error.empty()) error = "PleReader: submit failed";
if (!delayed.empty() && !release_delayed() && error.empty()) error = "PleReader: read failed";
@@ -274,11 +333,13 @@ PleReader::~PleReader() {
}
bool PleReader::open(const std::string& path, uint64_t table_offset, uint64_t n_rows, uint32_t max_inflight,
uint64_t cache_rows, std::string& err, bool io_thread) {
uint64_t cache_rows, std::string& err, bool io_thread, uint32_t row_bytes) {
close();
if (max_inflight == 0 || max_inflight > 1024) { err = "PleReader: max_inflight must be 1..1024"; return false; }
if (row_bytes == 0 || row_bytes > PAGE) { err = "PleReader: row_bytes must be 1..4096"; return false; }
if (!impl_->file.open(path, err)) return false;
if (table_offset + n_rows * (uint64_t) ROW_BYTES > impl_->file.size()) {
impl_->row_bytes = row_bytes;
if (table_offset + n_rows * (uint64_t) row_bytes > impl_->file.size()) {
err = "PleReader: the table extends past the end of " + path;
close();
return false;
@@ -291,8 +352,12 @@ bool PleReader::open(const std::string& path, uint64_t table_offset, uint64_t n_
impl_->inflight.assign(max_inflight, Job{});
impl_->free_slots.clear();
for (uint32_t s = max_inflight; s-- > 0;) impl_->free_slots.push_back(s);
impl_->cache.init(cache_rows);
impl_->cache.init(cache_rows, row_bytes);
impl_->error.clear();
impl_->keep_us = 0;
impl_->last_issue_us = impl_->last_read_us = 0;
impl_->rng = 0x9E3779B97F4A7C15ull ^ (uint64_t) now_us();
if (impl_->rng == 0) impl_->rng = 1;
reset_stats();
impl_->stop = false;
impl_->threaded = io_thread;
@@ -346,9 +411,12 @@ void PleReader::close() {
m.cache.init(0);
m.threaded = false;
m.stop = false;
m.keep_us = 0;
m.last_issue_us = m.last_read_us = 0;
}
bool PleReader::is_open() const { return impl_->file.is_open(); }
uint32_t PleReader::row_bytes() const { return impl_->row_bytes; }
PleReader::Ticket PleReader::issue(const uint32_t* rows, size_t n, uint8_t* out_raw) {
Impl& m = *impl_;
@@ -356,24 +424,28 @@ PleReader::Ticket PleReader::issue(const uint32_t* rows, size_t n, uint8_t* out_
if (m.threaded) lk.lock();
const uint32_t id = m.next_ticket++;
if (m.next_ticket == 0) m.next_ticket = 1;
const double now = now_us();
const bool rearm = m.keep_us > 0 && (m.last_issue_us <= 0 || now - m.last_issue_us > m.keep_window_us);
m.last_issue_us = now;
TicketState& ts = m.tickets[id];
std::unordered_map<uint64_t, size_t> by_page; // aligned offset -> index in `jobs`
std::vector<Job> jobs;
for (size_t i = 0; i < n; ++i) {
uint8_t* dst = out_raw + i * ROW_BYTES;
const uint32_t rb = m.row_bytes;
uint8_t* dst = out_raw + i * rb;
++m.stats.requests;
if (rows[i] >= m.n_rows) {
std::memset(dst, 0, ROW_BYTES);
std::memset(dst, 0, rb);
continue;
}
if (const uint8_t* hit = m.cache.find(rows[i])) {
std::memcpy(dst, hit, ROW_BYTES);
std::memcpy(dst, hit, rb);
++m.stats.cache_hits;
continue;
}
const uint64_t at = m.table_offset + (uint64_t) rows[i] * ROW_BYTES;
const uint64_t at = m.table_offset + (uint64_t) rows[i] * rb;
const uint64_t first = at / PAGE * PAGE;
const uint32_t length = (uint32_t) ((at + ROW_BYTES - 1) / PAGE * PAGE - first + PAGE);
const uint32_t length = (uint32_t) ((at + rb - 1) / PAGE * PAGE - first + PAGE);
auto f = by_page.find(first);
if (f != by_page.end()) {
Job& j = jobs[f->second];
@@ -400,6 +472,8 @@ PleReader::Ticket PleReader::issue(const uint32_t* rows, size_t n, uint8_t* out_
if (has_jobs) {
m.cv_work.notify_one();
m.file.wake(); // in case the worker is blocked in the port
} else if (rearm) {
m.cv_work.notify_one(); // the keep-alive had lapsed: start it again
}
} else if (!m.pump() && m.error.empty()) {
m.error = "PleReader: submit failed";
@@ -439,14 +513,33 @@ bool PleReader::collect(Ticket t, std::string& err) {
return true;
}
void PleReader::set_keepalive(double period_ms, double window_s) {
Impl& m = *impl_;
{
std::lock_guard<std::mutex> lk(m.mu);
m.keep_us = m.threaded && period_ms > 0 ? period_ms * 1000.0 : 0.0;
m.keep_window_us = window_s > 0 ? window_s * 1e6 : 0.0;
}
m.cv_work.notify_all();
}
void PleReader::set_injected_delay_us(double delay_us) {
std::lock_guard<std::mutex> lk(impl_->mu);
impl_->delay_us = delay_us < 0 ? 0 : delay_us;
}
const ReaderStats& PleReader::stats() const { return impl_->stats; }
ReaderStats PleReader::snapshot() const {
Impl& m = *impl_;
std::unique_lock<std::mutex> lk(m.mu, std::defer_lock);
if (m.threaded) lk.lock();
return m.stats;
}
void PleReader::reset_stats() {
impl_->stats = ReaderStats{};
impl_->ring_pos = 0;
Impl& m = *impl_;
std::unique_lock<std::mutex> lk(m.mu, std::defer_lock);
if (m.threaded) lk.lock(); // the worker may be counting a keep-alive read
m.stats = ReaderStats{};
m.ring_pos = 0;
}
uint64_t PleReader::cache_capacity() const { return impl_->cache.sets * WAYS; }
uint64_t PleReader::cache_size() const { return impl_->cache.used; }
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more