Compare commits

...
179 Commits
Author SHA1 Message Date
Niko1221 a3037d5435 Engine 0.1.30: short prompts up to 28% faster (every expert streams from 1024-token chunks), the resident low-RAM variant, the multi-GPU session carve (#216), AMD RDNA4, idle unload (#208); opt-ins: rope scaling (#84), conversation cache (#189), STRATA_IQ_MT_MIN=1 (#152); setup requires it 2026-09-30 19:34:50 +02:00
Niko1221 1e213213e3 docs: the layer split on two AMD RDNA4 cards (identity with one card, checkpoints, speed; setup offers one AMD card) 2026-09-30 19:20:16 +02:00
Niko1221 8099fdb980 tests/hip: prefill_native_batch passes the indexer a RopeScaling (the #84 signature; plain rope over base 1e4 as before) - a HIP-only test the CUDA build never compiled 2026-09-30 19:18:41 +02:00
Niko1221 31ad962727 #152 as an opt-in (the owner's choice): STRATA_IQ_MT_MIN=1 makes CPU expert rounding independent of the drafting; the default stays 0.1.29's rule (IQ3_S decoded 1-3% slower with it on); docs; the width test runs the opt-in 2026-09-30 19:03:39 +02:00
Niko1221 19a2dc169b tools: the cache-disabled gate compares every resolved setting except the engine version (release vs previous release) 2026-09-30 17:52:12 +02:00
Niko1221 fee66e89cc CPU experts: the multi-token kernels from one token on (#152): a token's expert rows round the same whatever its verify window held, so output no longer depends on the drafts; STRATA_IQ_MT_MIN=2 restores the old rule; native_expert_parity checks the width invariance bit for bit 2026-09-30 17:48:49 +02:00
Niko1221 59b8c45777 Merge f-rdna4 (AMD RDNA4 gfx1201, with doplxyz's patch) into rel-0.1.30
# Conflicts:
#	setup.py
2026-09-30 17:36:03 +02:00
Niko1221 dea2b5a3ec docs: RDNA4 (gfx1201) in AMD_HIP.md with the validation on the RX 9070 XT and R9700; how to calibrate a hipBLASLt table; README and setup's texts name the RDNA4 cards
AMD_HIP.md: the per-arch ROCm wheels, the system-ROCm rule, the table rule, the
arch list and the startup check; an RDNA4 section (ctest 42 pass / 1 skip /
2 non-GPU failures, the arch check, end-to-end 4K/16K numbers on both cards and
with setup's TheRock 7.10 wheels, the 9/9 server smoke on each card, why no
gfx1201 table is shipped, what is not validated); a Tuning table section with
the tune_hipblaslt command at the engine's shapes. setup's no-table message
points there.
2026-09-30 17:32:41 +02:00
Niko1221 70e24f43e3 doorbell_ring: a volatile store, so the host sees the ring on RDNA4 without a stream sync
On gfx1201 a plain store to mapped pinned memory stays in the GPU's L2 until the
stream is synchronized, so hip_handoff failed ("separate copy/ring timeout") on
both RX 9070 XT and R9700. A probe of the ring forms on both cards, graph and
stream launches, 100 rounds each: plain store 0/100 seen without a sync;
volatile store, plain store + trailing system fence, and a system-scope atomic
release store 100/100 (41-157 us), no stale payloads. The ring now uses the
volatile form doorbell_publish_kernel already uses. The ring kernel is the
--no-publish-kernel fallback; the default fused publish was already volatile.
2026-09-30 17:09:57 +02:00
Niko1221 4dcc8f5313 CMake: the D-1 prompt-attention parity tool is CUDA-only; the HIP build with tests configures again
qsa_prompt_attn_parity linked CUDA::cudart unconditionally, so
-DSTRATA_ENABLE_HIP=ON -DSTRATA_BUILD_TESTS=ON stopped at CMake's generate step
("CUDA::cudart ... target was not found"). The tool compares the tensor-core
kernel that HIP compiles out, so it is built with CUDA only.
2026-09-30 17:01:23 +02:00
Niko1221 862c5a4767 HIP: RDNA4 gfx1201 beside gfx1100; the startup check compares the card with the compiled archs; setup picks ROCm and the hipBLASLt table per arch
- CMake: CMAKE_HIP_ARCHITECTURES may list gfx1100 and gfx1201 (validated) and
  gfx1101 / gfx1102 / gfx1200 (build with a warning: same wave32, 64 KiB LDS and
  dot4, not run by us; #192, #176). The list reaches the code as STRATA_HIP_ARCHS.
- Runtime: the engine (every GPU a run uses) and strata-device compare the card's
  gcnArchName (up to the ':' feature suffix) with the compiled archs and require
  wave32, with a message naming the card, its arch and the build's archs, instead
  of a later "invalid device function".
- __dp4a uses __builtin_amdgcn_sudot4 (v_dot4_i32_iu8) on all five archs; the
  portable fallback stays for other compilers/targets.
- setup.py: AMD_ARCHS gfx1100 + gfx1201; TheRock's wheel index per GPU family
  (gfx110X-dgpu, gfx120X-all; the pinned 7.10.0a20251120 is on both), only that
  family's libraries on the path, reinstall when the family changes; a system
  ROCm older than 7.0 is passed over for the wheels; the engine is rebuilt when
  the chosen card's arch is not in its BUILD.json. The hipBLASLt table is
  tools/hip/<arch>-hipblaslt-<version>.txt for the card's arch AND the installed
  hipBLASLt (read from hipblaslt-version.h, 1.4.1 -> 100401), else none with a
  message; before, the highest-named table of the arch was taken whatever the
  installed version, which the engine then refused.
2026-09-30 16:57:59 +02:00
Niko1221 d263ec58f9 Conversation snapshots and checkpoints follow the session carve (#216): a session saves and restores only the layers it owns
conversation_session_sizes() gives one session's running-state sizes: the GDN bytes of its gdn_alloc
rows, the indexer tail/dead/block_pos per owned QSA state; an inconsistent carve is rejected.
Checkpoint save/validate/restore, the snapshot size estimate, capture, validation and restore iterate
the owned QSA ordinals [qsa_ord0, qsa_ord0 + qsa_alloc) instead of every model layer (the spare
pooled row is written back per owned layer only), and a parked snapshot records the carve it was
taken from (layer_lo, layer_hi): a restore into another range is refused before any write.

One GPU (the default full range: gdn_alloc = n_gdn_layers, qsa_ord0 = 0, qsa_alloc = n_qsa_layers)
saves and restores exactly the bytes it did before, in the same order. generate.cpp's
conv_state_sizes uses the same function. The tests' hand-built sessions carry the full-range carve,
and the host-only validation test checks the carved sizes and the rejections.
2026-09-30 16:57:11 +02:00
Niko1221 abbf12caf5 Merge f-189 (PR #189, the conversation snapshot core and RAM cache) into rel-0.1.30
Conflicts in generate.cpp:
- against #216's session carve: conv_state_sizes takes the session (the carve's signature) and scales
  the PR's whole-model GDN size to ss.gdn_alloc rows; checkpoint_save/checkpoint_restore are the PR's
  (conversation_state.cpp), which the next commit makes carve-aware.
- STATE_HASH: the per-GDN-layer line over ss.gdn_alloc (guarded for zero rows), the QSA loop over the
  owned ordinals [qsa_ord0, qsa_ord0 + qsa_alloc) with the PR's dead= and pooled_full= in it, and
  end_cell from ss.max_cells (the primary state's max_cells, and valid with no QSA layer).
- INFO: f-resram's arena_mib (resident bytes under --mmap-experts) and the PR's three
  conversation_cache_* fields.
The --conversation-cache-* options sit in the option parser's second chain (6fb466b); their value is
`number`, not `parsed`, which is that chain's flag.
2026-09-30 16:57:10 +02:00
Niko1221 840f01734e Merge f-resram into rel-0.1.30
# Conflicts:
#	src/program/generate.cpp
2026-09-30 16:54:12 +02:00
Niko1221 6fb466b7cb generate: the option parser's else-if chain in two statements (MSVC C1061: more than 128 nested blocks after the new options); the same order and effects 2026-09-30 16:53:53 +02:00
doplxyz ca574a27ad HIP: enable RDNA4 gfx1201 (RX 9070 / AI PRO R9700)
doplxyz's minimal patch from #178 (their commit 0c59582): CMake, the
runtime device check, the signed dot4 intrinsic and setup's AMD_ARCHS
accept gfx1201 beside gfx1100.
2026-09-30 16:52:13 +02:00
Niko1221 7a2948f9bb Merge f-coupled into rel-0.1.30 2026-09-30 16:51:52 +02:00
Niko1221 f9a31f391b setup: the resident low-RAM variant keeps its RAM copy pageable on ROCm (STRATA_RESIDENT_PIN=0 in the config's env) 2026-09-30 16:51:39 +02:00
Niko1221 12ebc67271 Merge f-84 into rel-0.1.30 2026-09-30 16:51:33 +02:00
Niko1221 5ac6d37e59 Low-RAM mode, resident variant: the experts the GPU does not hold stay in RAM (--resident-experts), chosen by setup
On a PC whose VRAM cache plus RAM can hold all experts but whose RAM alone cannot (e.g. 32 GB + a 24 GB GPU with
Q2_0 / IQ2_XS / the Coder), the plain low-RAM mode (--mmap-experts) reads the non-VRAM experts through the OS file
cache, which such a PC keeps giving back to the SSD. The resident variant copies exactly those experts from
experts.bin into RAM once at start:

- engine: --resident-experts = --mmap-experts --resident-cpu-experts, the copy page-locked when the driver allows
  (else locked in the working set), 4 GiB headroom, plain mmap with a warning when it does not fit. Built after the
  prompt path's lend plan: the lendable slots' experts are kept in RAM too, from the last slot down, as far as RAM
  allows. Adaptive swaps stay on: an evicted expert is copied back from its VRAM slot into the RAM place of the one
  that replaces it (exchange buffers, committed when the swap lands), so no swap reads the file.
- --resident-cpu-experts (the ROCm arm) no longer requires --adapt-every 0 and gets the lend region too; its copy
  stays pageable with 8 GiB headroom as before.
- the PCIe share is gated per source (pcie_layer): the arena answers as before; a page-locked resident copy answers
  for every layer. Same bits as --mmap-experts for the same placement with --pcie-frac 0 or STRATA_RESIDENT_PIN=0.
- INFO arena_mib reports the RAM the experts take in the mmap modes (the resident copy, or 0); the serve log and the
  CLI report the resident GiB, exchanges and blob reads from the file.
- setup: in the low-RAM mode, resident when RAM >= (experts minus the GPU's estimated share at this context, less the
  image encoder's room) + 10 GB; --low-ram resident|mmap forces a variant; needs engine 0.1.30 (else mmap).
- tests: lend-region choice and exchange bookkeeping (CPU only). Default (arena) path unchanged.
2026-09-30 16:51:04 +02:00
Niko1221 3b4230a182 Coupled draft sampling for the MTP drafter (STRATA_SPEC_COUPLED=1, off by default)
Under sampling the draft layer proposed its argmax. With STRATA_SPEC_COUPLED=1 a sampled request's drafts are
sampled with the target's own chain (top_k / top_p / min_p / temperature, penalties over the same history) and the
same Philox uniform the verify window draws for the row that checks the draft: counter = MTP cell + 1 (a draft at
cell p + a + j is checked by row j of the next window, counter p' + j). Verification is still exact-match against
the target's sample, so the output does not depend on the drafts; acceptance should rise.

- sampler.cu: coupled_stage / coupled_penalize / coupled_merge kernels (params, seed, counter and penalty ring from
  device memory, so they live in the drafter's CUDA graphs); the split part kernel reused unchanged; the tail
  (sampled_tail_warp) templated to also return the pick's probability. The five existing sampler kernels compile to
  identical SASS (sm_120).
- mtp.cpp: separate coupled round/step graphs (the argmax graphs are untouched and still used for greedy requests);
  subset-aware (sample over the draft head's subset, id <-> subset maps for the penalties); --spec-min-p gates on the
  draft's probability of its sampled token under the chain.
- generate.cpp: set_draft_sampling per request (serve and CLI), the penalty history base before each draft (serve).
- coupled_draft.hpp + coupled_draft_test.cpp: the counter / ring arithmetic and a host-only test (907k checks).

Switch off: no allocations, no new launches, the same graphs.
2026-09-30 16:46:51 +02:00
Niko1221 4240629dba docs: rope scaling is experimental and opt-in; the measured perplexity at 293K
DETAILS.md: what it does, that no flag means no change, the contributors' paired per-position NLL (yarn 2 -0.18 nats vs none and linear 2 at 293K, no separation at 2.7K/32K, needles not discriminating), the explicit-factor pass-through, fixed per run. The stale 'engine 0.1.18' references go; the setup's 384K/512K menu entries and the README say experimental.
2026-09-30 16:44:48 +02:00
Niko1221 eb77a8c145 rope: one validity rule for the resolved config; none passes the identity constants to every analytic site
rope_scaling_invalid() checks every value with std::isfinite first (a NaN passes every range comparison), factor >= 1, original context > 0, a finite positive freq_scale and mscale; the engine applies it to the resolved config (CLI + model file) and fails at startup with the reason, and native_rope, both native indexer appends and the prefill rope use the same rule instead of three hand-copied lists. RopeScaling::kernel_args() gives the analytic kernels their float constants and returns the identity (1, 0, 0, 0, 1) for none whatever the other knobs hold, as the table builder already ignored them: an unscaled run cannot pick up a stray --yarn-attn-factor on the native path only. none resets the knobs (with a note if some were given). The magnitude-correction note prints whenever mscale != 1, and only then (not for plain linear).
2026-09-30 16:44:48 +02:00
Niko1221 d4526cf156 qsa: the table indexer's spare rotates at position 0 like the native one (TODO 7)
indexer_key_append_kernel skipped the rotation at pos 0, which is only an identity while row 0 of the table is (1, 0). Under YaRN row 0 is (mscale, 0), so dead and pooled[0] stayed the only unscaled keys of a scaled cache and disagreed with the native indexer's spare. The rotation at angle 0 reduces to v * cos_tab[pair] on the rotated dims (the sine is 0 in every scaling), so no partner read and no barrier; unscaled it is v * 1.0f, bit for bit the old value. qsa_parity checks the table spare against the unscaled spare times row 0 (bit-exact), pooled[0] == dead, the native spare's factor, and that YaRN's mscale is observable there.
2026-09-30 16:44:48 +02:00
Niko1221 56c7cd2f80 STATE_HASH: pooled= keeps its 0.1.29 meaning (the completed indexer rows); the extent with the spare row is pooled_full=
pooled= hashes rows [0, L/4) of every QSA layer's idx_pooled again, as in 0.1.29 and earlier, so a
state hash from this engine compares with an older one field by field. The PR's extent (those rows plus
the spare row at L/4, which a conversation restore writes back) is pooled_full=; dead= stays. The
cache gates compare pooled and pooled_full; the cache-off gate against an older engine compares pooled
directly and needs pooled_full only as proof that the candidate is this build.

A failed checkpoint save/restore prints its reason to stderr (the ERR line has none). The cache
harness reads its files as UTF-8 (Windows' default code page stopped it at the tokenizer), and the
isolation gate passes absolute fixture paths (the engine runs in the config's cwd).
2026-09-30 16:43:28 +02:00
Niko1221 81bd596242 wip: version 0.1.30 2026-09-30 16:37:30 +02:00
Gopi 915f74fefb Multi-GPU: the session carve and the per-stage prompt loans
Two things a layer split was paying for and not using.

* THE SESSION CARVE.  A stage carved state for ALL 48 layers whatever slice it ran - every QSA KV pool at
  max_cells, every GDN recurrence - the whole-model-sized-buffer disease the chunked-QSA-prefill PR cured
  upstream.  `session_bytes`/`session_init` take [layer_lo, layer_hi), and a session now owns only that range:
  `qsa_states`/`gdn_state` keep GLOBAL ordinal indexing and simply hold fewer rows (`qsa_ord0`/`qsa_alloc`,
  `gdn_ord0`/`gdn_alloc`), per-layer consumers subtract `gdn_ord0`, and the model-level uses - the shared RoPE
  table, the prefill staging identity, the MTP rope borrow - read `qsa_states[qsa_primary()]`.  A range with no
  QSA layer still carves ONE primary state so those uses stay valid, and the default full range is
  byte-identical to the old carve.  On the 2x3060 + 2x5060 rig, CUDA1's [30, 48) session costs 0.93 GiB:
  7.83 GiB free before the carve, 6.90 GiB after (`session [30, 48) and the head`).

* PER-STAGE PROMPT LOANS.  `no_prefill_borrow = true` for split stages said "each stage's prompt path has its
  own buffers" - true, but that is a reason to give each stage its own LOAN, not to make every stage withhold a
  chunk-sized reserve from its cache for the whole session, and forced on it also collapsed `--prefill auto` to
  2048.  Each prompt path now borrows the tail of ITS OWN stage's cache (`PfPart`), so outside the prompt the
  whole cache is expert cache: `--prefill auto` is the largest chunk every participant can lend (8192 tokens
  here), a participant refills its loan into its own slots and marks only its own layers (a slot refilled into
  another cache, or another stage's rows marked resident, is silent and produces plausible tokens), and the
  chunk is the largest one EVERY participant can lend rather than one card's answer imposed on the others.
  A participant that cannot lend the buffers falls back to the prompt path's own, as a lone one always could;
  with a single participant the plan reduces to `plan_lend` exactly, so the single-GPU loan is unchanged from
  main - the percentage cap stays an AUTO-chunk rule (an explicit `--prefill` is the operator's number and a
  loan of it only has to leave the 128-slot floor).  The flat 1 GiB withheld from every stage after the first
  becomes what is actually held back after the weights, session and drafter exist: 96 MiB of verify windows,
  plus 1000 MiB of drafter and head on the ONE stage that carries them.  CUDA0 lends 2162 slots (3.98 GiB),
  CUDA1 1993 of its 2653 (3.97 GiB).

* REPORTING.  `expert_slots`/`expert_cache_mib` sum every tier: a 4-GPU 256K run reported CUDA0's cache alone
  (3327 experts) while the four cards held 13310, so the Monitor tab was wrong by 4x for every multi-GPU
  config.  `expert_slots_primary`/`expert_cache_primary_mib` keep the old figure.  The helper ranks'
  ms_begin/ms_wait are cumulative since boot and were printed next to per-request deltas; they take deltas now.
  KV-streaming stats iterate the session's OWNED ordinals.

* REFUSALS.  `--gpu-only-full` and `--gpu-stages` replay every layer through CUDA0's session, which a split
  carves to CUDA0's range, so they are refused with a layer split rather than answering about another stage's
  state; the token-graph hit path is disabled under a split (such a graph cannot span stages); and the sessions
  are allocated AFTER the split search, because the search prices each placement by what that range's session
  will cost on that device (`session_bytes` is pure arithmetic).
2026-09-30 16:37:18 +02:00
Go 4f5d0860d7 docs: limit shared arena backing to dev shm 2026-09-30 16:36:33 +02:00
Daehun 80f83268d4 core: add guarded shared expert arena backing
Rebase the shared-arena primitive onto 0.1.26, bind backing files to a pack fingerprint with a small header, and document tmpfs/hugetlbfs placement.
2026-09-30 16:36:32 +02:00
ruibeikaa e200e08f54 Pascal (sm_60): bring the engine up on compute capability 6.0
Two gates stand between this engine and a 6.0 card, plus one attribute the card does
not honour.  None of them needs the kernels rewritten:

* `CMakeLists.txt` refuses to configure below sm_75;
* `__dp4a` (6.1+) appears 29 times in the i-quant kernels (`native_mmvq.cu`,
  `s2_expert_grouped.cu`, `iq_kernels.cu`) and `__nanosleep` (7.0+) in the three
  doorbell waits (`elementwise.cu`, `verify_kernels.cu`);
* the Turing port sizes `fused_gr_read_multi`'s slice from
  `cudaDevAttrMaxSharedMemoryPerBlockOptin`, which on an sm_60 answers 65536 - a card
  whose launch validator enforces the 49152 B per-block limit instead.  The capacity
  comes out 6 tokens and every 6- or 8-token window fails the launch with
  "invalid argument".

## The changes

* **`include/strata/kernels/dp4a.hpp` (new)** - `STRATA_DP4A` and `strata_spin_pause()`.
  The dp4a fallback is llama.cpp's own (`ggml/src/ggml-cuda/common.cuh`): both operands
  read as SIGNED bytes, accumulated into int32 - which is what `__dp4a` does for exactly
  these call sites, the kernels here being transcribed from `vecdotq.cuh`/`mmvq.cu`.  The
  fallback is bit-exact, not approximate; the nanosleep sites only pace single-thread
  doorbell waits, so on sm_6x the loop spins.  Both are `__CUDA_ARCH__` selections, so a
  HIP build takes neither: its `__dp4a` and `__nanosleep` are `hip_compat`'s own.
* **`CMakeLists.txt`** - `-DSTRATA_EXPERIMENTAL_SM60=ON` relaxes the architecture gate,
  and the refusal message names it.  The UTF-8 BOM stays.
* **`fused_gr.cu`** - below sm_70 the slice capacity comes from
  `cudaDevAttrMaxSharedMemoryPerBlock` (49152 B -> 4 tokens) instead of the opt-in.  The
  Turing port's slicing, its no-opt-in fallback and the HIP expression are unchanged.

On sm_70 and newer this is a no-op: the opt-in is honoured there, so the capacity is the
value it already was, and `STRATA_DP4A` and `strata_spin_pause()` compile to `__dp4a` and
`__nanosleep` exactly as before.  Building for a 6.0 card needs a CUDA release that still
targets Pascal (CUDA 13 removed it; 12.x has compute_60), and `CMAKE_CUDA_STANDARD` has
to stay 17 for it: nvcc 12.9 refuses `-std=c++20` when the host compiler is VS2019's `cl`
and silently falls back to C++14, which rejects the nested
`namespace strata::kernels {` declarations.

Rebased onto 0.1.27 (0.1.21 -> 0.1.24 -> 0.1.27).  The 0.1.21 form of this patch clamped
the MTP and verify windows instead, because the fused GR read could not launch a 6-token
window at all.  Now that `fused_gr_read_multi` slices the batch itself, the clamp, its
`fused_gr_max_window` helper and the two `generate.cpp` call sites are gone, and the full
window runs:

    strata verify: window up to 6 tokens, 69.1 MiB of device buffers
    strata verify: captured the 6-token window (upload no error, sync no error)

Measured on a 6.0 card (GP100-class, four dies through the layer split, the model's
IQ3_XXS quant, an 883-token prompt, 256 generated, the shipped `--spec 4`): every one of
the 24576 experts resident, 100% decode cache hit rate, 110 tok/s prefill and 21.3 tok/s
decode (warm median).  This port on the 0.1.21 base measured 110.6 and 17.9 in the same
session with the same prompt, so the full window is worth about 19% of decode here.  One
RTX 3090 Ti with the release engine measures 305 and 79 tok/s for the same model.  The
port is about fitting, not speed: decode is bounded by Pascal's per-layer arithmetic, not
by capacity or memory.  Only the 6.0 path was exercised on hardware.
2026-09-30 16:36:32 +02:00
Strata b782066e3c Docker build (#96): the engine compiled in docker build, STRATA_EXECV so the server is PID 1, SIGTERM stops it cleanly, KV/GPUS/LOW_RAM as env vars
Merged by hand onto 0.1.29: SIGTERM takes #153's Ctrl+C path (the timed sleep loop and the second-Ctrl+C kill stay);
SIGINT keeps Python's handler; the image builds RTX 20 (75) too.
2026-09-30 16:36:25 +02:00
Niko1221 379d7d84b2 server (#208 follow-up): a request stamps last_request_at in load() (the idle thread cannot unload between load and run); unload() sends QUIT before terminate; the VRAM guard checks the first GPU (docs); #163's test engine answers poll() 2026-09-30 16:35:05 +02:00
bytethecookie 376430c7db server: the image encoder unloads and loads with the engine; docs for sharing the GPU 2026-09-30 16:32:11 +02:00
bytethecookie f61e688527 server: optional idle unload, POST /unload and /load, a free-VRAM guard and a before_load hook 2026-09-30 16:32:11 +02:00
Niko1221 3e3d69b4d4 prefill: every expert streams from 1024-token chunks (was 2048): +17-28% on 1-4K-token prompts, the same output; STRATA_PREFILL_STREAM_MIN overrides 2026-09-30 16:32:04 +02:00
Jeremiah Ritchey 7e94070cf2 Document retained snapshot ownership and admission contract 2026-09-30 16:31:46 +02:00
Jeremiah Ritchey 7d93cb6b03 Require fixed binaries and budgets in the incremental capture gate 2026-09-30 16:31:46 +02:00
Jeremiah Ritchey 92a3d5fda7 Bound incremental snapshot fragmentation and preserve cache admission
Reserve accounted space for later appends without moving prior payloads. Prefer full capture when optional capacity would evict useful conversations, and report the actual additional RAM required. Exercise 4096 small appends and non-mutating admission checks under sanitizers.
2026-09-30 16:31:46 +02:00
Jeremiah Ritchey 7f7f886886 Retain unchanged KV storage across repeated conversation parking
Grow snapshot buffers in segments, track the first rewritten token, and charge retained active storage to the existing RAM budget. Refresh running state, partial pages and draft boundary state. Add full-capture versus incremental growth/rewind checks and report snapshot sizes with usable pressure-test budget ranges.
2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 573bb36baa Compare cache-disabled engines against untouched upstream
Replay identical requests and require token, known-answer, reuse, resolved-setting and comparable main-state parity. Retain the complete pooled-state diagnostic and add a completed-row fingerprint matching upstream. Add negative controls that remain active under optimized Python.
2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 0fca77bfe0 Remove planning documents from the core PR 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 3782659274 Ignore llama.cpp dependency directories and symlinks 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 3a5b090014 Record final Linux snapshot and RAM cache acceptance 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 8d46e95ff5 Record linked 0.1.27 core build and host validation 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 876f254c78 Report actual draft residency in snapshot verification 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 75fce92c93 Refresh core validation record for upstream 0.1.27 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 13fd32a227 Verify restored draft KV and fail incomplete state fingerprints
Move core diagnostics from the dependent NVMe work (4d73bb3) into the shared-core review branch. Read back authoritative bytes and resident ring pages without rewriting state; remove a redundant GPU fixture refill that could mask restore failures. Bound hash workspace and check synchronization/copy errors. Opt-in diagnostics record batched versus token draft prefill.

Validation: current core translation units pass C++ syntax checks; 1188 host transfer checks pass with ASan/UBSan. Real GPU and full-model validation remain pending the benchmark window.
2026-09-30 16:31:45 +02:00
Jeremiah Ritchey f3072a95e6 Clear recoverable snapshot errors before batched draft prefill 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 4ac028522e Focus shared-core patch on runtime, regression tests and current documentation 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 28d07f8a1a Record upstream integration, Linux acceptance and controlled Pi results 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 717519d345 Allow bounded Pi task completion and record benchmark provenance 2026-09-30 16:31:45 +02:00
Jeremiah Ritchey 055bc16e2a Match upstream K8V4 flags when sizing conversation snapshots 2026-09-30 16:31:44 +02:00
Jeremiah Ritchey 089d444278 Enforce Pi benchmark request limits and engine memory evidence 2026-09-30 16:31:44 +02:00
Jeremiah Ritchey e0feb8530c Add reproducible Pi cache comparison and document current integration 2026-09-30 16:31:44 +02:00
midhatn 6adb2e5ac4 test: require a real physical-memory sample on Windows 2026-09-30 16:31:44 +02:00
Jeremiah Ritchey 991203f2a9 Integrate validated conversation snapshot core with upstream 0.1.25 2026-09-30 16:31:44 +02:00
Jakub Luwierski a8e56761a6 generate: the in-window scaling note stops claiming YaRN's magnitude correction (TODO 22)
The note fired for every scaled run inside the trained 262144 and its text
named YaRN's magnitude correction even for linear, whose resolved mscale
is exactly 1.  Split in two: the in-window warning now only says that no
position needs the extension while the resolved scaling still applies to
every angle, and the magnitude explanation became its own note, printed
only when YaRN is resolved and its magnitude multiplier differs from 1
(yarn factor 1 inside the window stays quiet; linear never sees it).  The
main rope scaling line is unchanged, the numbers it prints already carry
the resolved mscale.

Diagnostics only.  Checked on the rebased tree: none prints nothing,
linear 2 prints the in-window note without the magnitude line, yarn 2
prints both (mscale 1.069315), yarn factor 1 prints the in-window note
only, and yarn 2 past the trained window prints the magnitude note
without the in-window one.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 8e2f46ca8c prefill: the draft layer's batched RoPE takes the one rope config
v0.1.29's new draft_kv site (the 0.1.26 batched draft prompt pass) kept
upstream's float freq_base form through the rebase, which both misses the
scaling on the draft K/V and does not compile against the RopeScaling
signature the other four sites carry.  It passes rope_scaling() now, like
the prompt K, Q and indexer-query sites.
2026-09-30 16:31:31 +02:00
Jakub Luwierski f8c45322b3 test: the indexer batch-vs-sequential parity covers linear and compares bits
Item 8 verification.  The parity section now runs all three scaling
configs - none, linear 2 (angle shrink only), yarn 2 (ramp + mscale) -
and "bit-identical" is asserted on the BIT representations (memcmp of
the state words), not float !=, which would pass a NaN-payload or -0.0
flip.  Each scaled variant must also differ from the none baseline in
pooled-key bits, so the parity cannot pass because both sides ignore
the scaling.  Green on this tree: 0 of 33409 state words differ in
every config; 128 (linear) and 192 (yarn) pooled words move.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 29f3aec04d test: pin the short-context rope table (§4, no factor-2 fallback in the window)
The implementation landed with the derived-factor change; per the item's
own scope note this is verification and tests, not a second one.  The
table from the item is now pinned case by case - 131072 and 262144 with
yarn and no factor resolve to 1, an explicit --rope-scale 2 stays 2 inside
the window, 393216/524288 keep 1.5/2 - and linear is checked under the
same rule the old fallback applied to it too.  On the real engine the
resolved combo runs as "rope scaling yarn, factor 1 (freq_scale 1, mscale
1.000000)" and a greedy 12-token run is token-identical to the stock
baseline - evidence on one path, not a bit-identity claim.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 79926c0d3f setup: the automatic rope factor derives from the context actually served
The factor ladder (smallest of 1.5/2/3/4 covering the chosen context) is
gone.  The automatic factor is now the final context over the trained
262144, clamped at 1 - max(1.0, final / trained) - computed AFTER the
RAM reductions, so it always matches the context the engine will serve:

  512K + IQ3_S under 90 GB of RAM + yarn, no factor:  final 128K, factor 1
  no reduction:                                       384K -> 1.5, 512K -> 2
  a chosen method inside the trained range:           factor 1, not 2

Factor 1 removes the automatic expansion - the trained angles stand - but
it is not a switch for rope as a whole: an explicitly chosen method's
settings keep their defined behavior.  A user-supplied --rope-scale is
still kept verbatim when a reduction changes the context; the ok() line
now says where the factor came from (final context / trained, or as
requested), and help, docstring and docs/DETAILS.md drop the
"smallest covering factor" wording.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 00db760854 setup: a free-form --context past the menu got a raw StopIteration
The triple-check pass found what the menu could not: --context is not
limited to CONTEXTS, and the factor ladder (1.5, 2.0) ended at 512K, so
`--context 600000` died with a bare traceback instead of a setup message.
The ladder extends (3, 4) and a context past its top refuses cleanly,
naming --rope-scale as the way out.  The interactive explanation block
also stays quiet under --yes, whose ok() line announces the choice
already.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 9b83998d9d setup: the documented automatic rope configuration actually happens now
The comment said the setup adds the flag when the chosen context needs it;
the code called fail().  `START-HERE.bat --setup --context 393216` - the
command the docs give - stopped instead of configuring.  Now an omitted
--rope-scaling with a past-trained context resolves the config itself:
yarn (llama.cpp's extension method, recall-tested here at 512K; one
question when interactive, the silent default with --yes) and the smallest
covering factor (1.5 at 384K, 2 at 512K).  An explicit none is still
refused for such a context, with the reason and the way out spelled out
instead of a bare stop; explicit linear/yarn/scale pass verbatim; inside
the trained 262144 nothing turns on by itself.

The resolution moved AFTER the RAM reduction (the §4 ordering): the factor
must cover the context the engine will serve, so a 384K choice reduced to
128K by the RAM limit gets no scaling at all, and an explicit selection
that survives the reduction says so in a warning.

The rope config is now two pure functions (covering_factor, resolve_rope)
with its own unit tests (tools/test_setup_rope.py); the engine args take
the resolved values; help, module docstring, the context menu note, README
and docs/DETAILS.md describe the implemented behavior.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 9adfaa42e5 indexer: the batched append takes the one rope config, with a parity test
The rebase onto 0.1.24 had brought the QSA indexer's C-2 chunk append,
whose kernels rotated the pooled keys inline - cosf/sinf of the unscaled
angle, no mscale - so a scaled prefill would have written indexer keys
under a different rotation than the per-token append the decode path uses
(rope_scaling.hpp's one-rotation rule, broken silently).  The batch path
now resolves freq_scale/corr_dims/mscale from the same RopeScaling the
single append reads and rotates through the shared rope_scaled_angle; the
raw freq_base parameter is gone from its signature.

qsa_parity pins the contract the C-2 rework claimed: the batch append's
whole end state (pooled, spare, tail ring, block_pos) is BIT-IDENTICAL to
the sequential single appends', under none and yarn 2 alike, and the
scaling is observable in the batched keys.
2026-09-30 16:31:31 +02:00
Jakub Luwierski f4ae11b736 bench: rope-scaling needles past the trained 262K (linear 2, yarn 2)
The first end-to-end check that a scaled run recalls past the trained end:
8 of 8 probes found the needle at mid-depth, temperature 0 - 1k/32k/128k on
the stock model (arm A), 262k and 512k prompts on linear factor 2 (arm B)
and yarn factor 2 (arm C), 512k being 2.01x the trained 262,144.  The yarn
262k probe was repeated green after the rebase onto 0.1.20, next to the
per-arm engine and server logs and the launch recipes.

What it does not establish is written down in the README: one depth, one
needle per length, and no long-document perplexity with and without
scaling at 262K yet - the one open measurement.
2026-09-30 16:31:31 +02:00
Jakub Luwierski c7d2766528 generate: an explicit --rope-scaling none or --rope-scale 1 overrides the GGUF rope keys
The defaults collided with the opt-outs: "none" was both the untouched
--rope-scaling default and the explicit switch-off, and 1.0 the same for
--rope-scale, so qwen4exp.rope.scaling.type/factor from the model file won
even against an explicit flag - the opposite of the stated precedence (and
of docs/DETAILS.md).  Now the empty string and 0 mean the flag is absent
(the same sentinel idiom as --rope-freq-base 0 and --yarn-orig-ctx 0), the
model file fills only what no explicit flag named, and `none` / `1` are
the opt-outs they read as.

The resolution moves above the weight pipeline: the config needs only the
CLI and the model file's metadata, its one hard constraint (set before
session_init builds the table and the graphs capture the constants) is
unchanged, and a bad rope key now dies before the shards are scanned.

Checked on the bench machine against a synthetic GGUF carrying
rope.scaling.type yarn / factor 4: the flags absent -> yarn 4; --rope-scaling
none -> no scaling; --rope-scale 1 -> yarn, factor 1, mscale 1; explicit
linear 2 -> linear 2; a GGUF type other than none/linear/yarn dies before
the weight scan.  The real artifact (no rope keys) starts and generates
unchanged, and rope_parity stays green.
2026-09-30 16:31:31 +02:00
Jakub Luwierski c1d061faa9 docs: context extension (rope scaling)
DETAILS.md gains the 'Context extension' subsection - what linear and yarn do,
the setup path (383K/512K in the context menu pick the factor themselves), the
engine flags with llama.cpp's names and their defaults, why the scaling is fixed
for the whole run (K sits in the cache post-RoPE), and the one honest caveat:
inside the trained range a scaled run is a slightly different model, and the
recall numbers are text-only at one depth.  Troubleshooting grows the row for
the refusal a context past 262,144 now raises without the flag; README points
the context question at it.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 9ede9d94bc setup: --rope-scaling/--rope-scale; contexts past 262144 require scaling
CONTEXTS grows 383K/512K, labelled in the menu, and the setup refuses a context
past the trained 262,144 without --rope-scaling (the engine would refuse it
too).  The factor defaults to the smallest that covers the chosen context
(1.5 at 393K, 2 at 524K) and lands in the engine args like every other flag.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 963679b35e engine: --rope-scaling/--rope-scale/--yarn-* flags; rope keys as GGUF defaults
Nine flags, llama.cpp's names: --rope-scaling none|linear|yarn, --rope-scale,
--rope-freq-base, --rope-freq-scale, --yarn-orig-ctx, --yarn-ext-factor,
--yarn-attn-factor, --yarn-beta-fast, --yarn-beta-slow.  Ranges die in the
validation block at second zero; the config itself resolves after the model file
has had its say and before session_init builds the table or captures a graph
(rope_scaling.hpp's one-writer rule).  Precedence: CLI over the model file's
qwen4exp.rope.* keys over the struct defaults - today's artifact ships no rope
keys, so that channel is a no-op defaults path for future fine-tunes.

A scaled run prints its resolved summary (type, factor, freq_scale, base,
mscale, context vs trained) to stderr, which is what the bench logs quote, and
warns when scaling is on with the context still inside the trained range -
YaRN's magnitude correction applies everywhere, so that run is not the stock
model.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 0011637753 rope: thread scaling through the native, indexer and prefill rotation sites
The table path got scaling for free (it lives in the table contents); the three
ANALYTIC paths computed the angle in-kernel and so need the config explicitly.
Each wrapper now takes the RopeScaling and passes the resolved constants -
freq_scale, the corr_dims pair, ext_factor, attn_factor - as kernel arguments.
They are process constants set before session_init, so baking them at CUDA-graph
capture is correct by the same argument as every other kernel argument: only
per-token values must ride device memory, and positions still do (st.pos_dev).

The shared float32 arithmetic is rope_scaled_angle (rope_scaling.hpp), a
line-for-line rope_yarn: one definition for native_rope, the native indexer's
pooled-key rotation and prefill's rope, instead of three transcriptions that
could drift.  The spare indexer key keeps its zero angle; under YaRN the mscale
rides in through cos(0) on both pooled keys and indexer queries, so top-k is
unmoved.  rope_parity adds the check this whole wiring answers to: native vs
table on the same input, yarn and none alike - which caught the first draft
passing scaling.mscale() into the helper that applies the log term itself, a
double magnitude correction visible as ~7% on every rotated dim.
2026-09-30 16:31:31 +02:00
Jakub Luwierski 304d613cd0 kernels: rope scaling config and the scaled cos/sin table (none/linear/YaRN)
The artifact pins theta 1e7 with no rope.scaling keys, so the engine had exactly
one rope: inv[i] = theta**(-2i/n_rot), no freq_scale, no correction.  Contexts
past the trained 262,144 had no coherent path.

rope_scaling.hpp adds the one process config - none | linear | YaRN, llama.cpp's
types - and build_rope_table grows an overload that folds the whole scaled angle
(ggml's rope_yarn: interpolation, the corr_dims ramp along the pairs, and the
mscale magnitude correction) into the float64 host table.  The rotation kernels
cannot tell a scaled table from an unscaled one, so nothing downstream changes:
for the default decode path and the indexer's pooled key, scaling IS the table.

The math is transcribed from the vendored ggml (ggml.c corr_dims, ggml-cuda/
rope.cu rope_yarn/ramp, MIT headers kept), not re-derived; the one deliberate
deviation is bookkeeping - the ramp takes the PAIR index ggml's i0/2 would
produce, because every Strata rope kernel speaks in pairs.

For type None the new builder calls the original loop verbatim, and the parity
test holds it bit-identical; linear(4) is checked bit-exact against a float64
reference and equal row-for-row to none at p/4; YaRN against a float64
transcription of the ggml spec, plus the structural checks a tolerance cannot
fake: cos_tab[0] == mscale (position 0 is angle 0 for every pair), first pair
extrapolates / last interpolates at a far position (cos for the first, sin for
the last - near zero sin IS the angle, which is where interpolation happens),
and an observability assertion that the fixtures see scaling at all.
2026-09-30 16:31:31 +02:00
Niko1221 0ea2fc3176 Engine 0.1.29: sampled answers up to 40% faster with the same tokens (#197), faster prompt kernels (#187, #188, #207), correctness fixes (#154); setup requires it 2026-09-30 14:01:53 +02:00
Niko1221 bb628ce12f A fatal CUDA fault in the prompt path exits at once instead of hanging until the watchdog (#224); CMake warns when sm_120 is built with CUDA < 13.0 (#220, #224); iq_pack writes native_experts.txt under a temporary name, so a stop part-way leaves no partial pack (#172) 2026-09-30 14:01:52 +02:00
Niko1221 c3242f095c generate: the session state is zeroed for every pack in CLI generate mode (#167 follow-up); no .pi/ ignore line 2026-09-30 14:01:52 +02:00
Enky 15dd501b05 tests: bitwise check of native_mmvq's multi_exact contract, with a negative control (#148; CMake on the backend variables, CUDA-only) 2026-09-30 14:01:52 +02:00
samuelishida 0ace554231 generate: zero session state for a native pack before the prompt path
The canonical path zeroes session state from put_input at position 0, but a
native pack breaks out of that loop before the first put_input and runs verify
windows instead, leaving cudaMalloc'd state uninitialized. On a reused
allocation that produced NaN/overflow and degenerate identical-token output.
Zero the state explicitly when native_pack is set.

Also removes an unused argmax() helper that emitted a compiler warning, and
ignores the local .pi/ harness directory.
2026-09-30 14:01:52 +02:00
architectds b353a06128 setup: the 3-bit models' 262K context is counted against RAM, not a fixed 90 GB
IQ3_S and IQ3_XXS asked for 262K were cut to 128K on any machine under
90 GB of RAM. The limit is now counted: the experts' arena, plus the
context's 8-bit KV (in RAM with KV streaming, ~13.7 KB a token), plus
24 GB of room.

- A 64 GB PC still gets 128K for both models, as before.
- A box with ~84 GB keeps the 262K it asked for. A Colab A100-40G
  (83.5 GiB) runs IQ3_S at 262K, with images, in 56 GiB.
2026-09-30 14:01:52 +02:00
Hendrik PolczynskiandCodex ba1c809948 Show prefill speed alongside decode speed in dashboard
Display both rates in t/s with independent graph scales and a darker
prefill curve. Expose prefill_tok_s_mean using the engine's existing
rate, excluding cached tokens.

Co-authored-by: Codex (gpt-6-astra-medium) <[email protected]>
2026-09-30 14:01:52 +02:00
Joost R. 1564404f62 WSL2: survive the driver's ~1 GiB pinned host budget with 3 GPUs
Under WSL2 the NVIDIA driver can pin/map only about 1 GiB of host memory in
total (dmesg: "dxgk: create_existing_sysmem: establish_gpadl failed: -122").
With --expert-cache-device1/2 the startup ran out of it twice:

- CUDA2's context is created after CUDA0's weights and MTP, whose mapped host
  buffers have spent the budget: "CUDA2 experts: cudaSetDevice(2) failed: out
  of memory". STRATA_EARLY_REMOTE_CONTEXTS=1 now creates every secondary
  context up front, like CUDA1's (opt-in: the default order is unchanged).
- With three contexts up, the 260 MiB mapped native embedding no longer fits:
  "native embedding: cannot pin 260 MiB". It now falls back to a device copy
  on the current GPU instead of failing the start.

Measured in Docker Desktop (WSL2), RTX 5070 Ti + 2x RTX 5060 Ti, Swift 1.5
IQ3_XXS: starts and serves with both helper caches (5000 experts each).
2026-09-30 14:01:52 +02:00
Hasegawa Takuya e258e6ec5b perf(prefill): allow explicit routed-only staging for large chunks 2026-09-30 14:01:52 +02:00
Andy be41a09e5b ple: --ple-io ram: refused on Windows (no mlock), 'locked' only when mlock succeeded else 'loaded (not locked)', neutral comments + --help (review) 2026-09-30 14:01:52 +02:00
dsh 0d40c17d2f ple: --ple-io ram locks the n-gram table in RAM (no SSD read on the prompt/token path) 2026-09-30 14:01:52 +02:00
Andy 802c6619df prefill: GDN recurrence pipelining - the neutral 16-column variant (STRATA_GDN_CB) dropped, one kernel on upstream's split (review) 2026-09-30 14:01:52 +02:00
Andy ab50d19c9b decode: QSA scores multi - comment names every caller that takes it (decode captured or not, prefill's pooled16); no box-specific note (review) 2026-09-30 14:01:52 +02:00
Apposite245 4ff986a0aa serve: on Windows the engine, vision encoder and MCP servers end with the server
Closing the console window (or a crash, or Task Manager) ends the server without its cleanup, and strata --serve,
strata-vision and the MCP servers kept running with their VRAM and RAM. Each child now goes into a job object that
kills everything in it when its only handle - the server's - closes; what the children start joins it too. The
server itself stays out, so a browser --open starts is not tied to it. A no-op elsewhere.

serve/test_winjob.py: a contained child and its grandchild end when the process that contained them is killed.
2026-09-30 14:01:52 +02:00
Guillaume PUTIER 05d4cb11a5 sampler: top_k by lexicographic threshold, split over the whole GPU
The sampled path (penalties, top_k, top_p, min_p, temperature, draw) kept one
row on one SM: sampler_kernel excluded the logits already kept by sweeping the
kept list for every logit of every round, O(k^2 x n_vocab) shared-memory
compares per row, and ran the double-precision tail on all 1,024 threads.

Three implementations now pick the same token:

- Split (new default): stage 1 cuts each row into 4,096-logit blocks (61 per
  row at 248,320 logits), each warp keeps the top_k of its 1,024 penalised
  logits in registers, and the block merges its four warp lists. Stage 2, one
  warp per row, merges the block lists and runs the tail on one warp.
- sampler_one_block_kernel: one block per row, top_k rounds that each take the
  logits strictly after the previous pick in the selection order (value
  descending, id ascending). It is the fallback when the split cannot run
  (stream under graph capture, vocabulary above 262,144, more than 64 rows,
  no scratch) and is selectable with STRATA_SAMPLER_ONE_BLOCK=1.
- sampler_kernel, the previous kernel, kept unchanged as the reference and
  selectable with STRATA_OLD_SAMPLER=1.

The list, its order and the pick are unchanged: the first k of a union lie
within the first k of each part, and a merge of ordered lists is ordered. The
tail keeps the same arithmetic, with the exps and quotients shared across one
warp and the ordered sums and scans run by lane 0.

The block lists live in a per-stream scratch buffer grown on demand. A
grown-out buffer is retired rather than freed, and a failed grow is
remembered so later calls of that size do not retry cudaMalloc.

sampler_parity is the parity test. Fixtures 16 to 18 pin the whole top_k list
position by position under ties (+inf, -inf, NaN, +0/-0, sentinel id 0,
penalised tokens landing on other tokens' values), tied draws through the whole
chain, and the automatic fallbacks (graph capture, row cap). Each pick is an
integer token id compared for equality with a host reference. ctest runs the
binary once per path: sampler_parity (split), sampler_parity_one_block and
sampler_parity_old, so all three paths are held to the same expected ids.

hip_compat/cuda_runtime.h: map cudaStreamIsCapturing, cudaStreamCaptureStatus
and cudaStreamCaptureStatusNone to their hip equivalents, which the new
capture check needs on HIP builds.
2026-09-30 11:46:25 +02:00
Andy 4305fdc3fb decode: QSA block scores read each key block once for all of a window's queries (bit-identical; STRATA_SCORES_MULTI=0 = old grid) 2026-09-30 11:45:45 +02:00
Andy 18106b1c31 prefill: software-pipelined loads in the column-split GDN recurrence (bit-identical; STRATA_GDN_PIPELINE=0 = old kernel) 2026-09-30 11:45:45 +02:00
pipeob0 95883f46e8 E-2 on the AVX-2 path too: prefetch the i-quant expert rows (same switch, CPUs without AVX-512)
24d4013 put the software prefetch in iq_avx512.cpp, so it only reaches CPUs with AVX-512; the AVX-2 kernels, which is what a Zen 3 / Haswell / Skylake-client box actually runs the i-quant rows on, had none.  Same env, same default, same hint: STRATA_IQ_PREFETCH is the distance in bytes, 0 = off, default 2048.

Measured on a Ryzen 7 5700X3D (AVX-2 only, 64 GB DDR4-2666, 4 KB pages - large pages refused) with IQ3_S decode, 4 rounds interleaved, outputs identical:
  gate/up phase   13.571 -> 13.024 ms/round   (-4.0%)
  rows            25.1   -> 26.1 GB/s         (+1.0)
  whole round     45.54  -> 44.94 ms/round    (-1.3%)
and with the pool cold (first round after the arena is built) the round is -2.4%.  The same 4 rounds with STRATA_IQ_PREFETCH=0 land within 0.1% of the unpatched binary, so the patch itself costs nothing.  native_expert_parity: 0 failures with the prefetch on and off.

The non-temporal hint was measured worse than T0 at the same distance (-0.7% vs -1.3%, +0.5 vs +1.0 GB/s), so this keeps T0.  4096 is a little worse than 1024/2048, which is why the default stays 2048 as in the AVX-512 file.
2026-09-30 11:45:45 +02:00
Guillaume PUTIER 5b9a5029dc build: guard the last two bench/micro targets on their file, as upstream does for the others 2026-09-30 11:45:44 +02:00
Guillaume PUTIER 255bf03d3e gguf_reader.hpp: define NOMINMAX before windows.h 2026-09-30 11:45:44 +02:00
Guillaume PUTIER c9d3fd6877 expert_source, s2_expert_grouped: guard the verify window's fixed tables 2026-09-30 11:45:44 +02:00
Guillaume PUTIER a8d79afab7 qsa: mask cells whose page did not resolve; refuse a clock sweep over fewer slots than the resolve block 2026-09-30 11:45:44 +02:00
Guillaume PUTIER e2b0cbcde9 bf16: a NaN stays a quiet NaN in f32 -> bf16, as in ggml 2026-09-30 11:45:44 +02:00
Guillaume PUTIER 91e8d21227 s_gemv: the q8 split kernel reaches its barrier before a rowless warp returns 2026-09-30 11:45:44 +02:00
Niko1221 10409135dd Engine 0.1.28: the expert cache reserves the draft head's VRAM (0.1.27 left 16 GB cards below the stall line), a cancelled request no longer fails the next one, the API key protects /status; setup requires it 2026-09-30 11:18:54 +02:00
Niko1221 03939f1c06 docs: the draft layer's token subset and --draft-vocab 2026-09-30 11:18:54 +02:00
Niko1221 75e6efeb77 setup: --host/--api-key/--draft-vocab on a start are kept for the model (#179); --draft-vocab en keeps the English/code subset (~110 MiB less VRAM); CUDA 13.0 for RTX 50 builds (#220); llama.cpp's web UI is not unpacked (Windows path limit, #206); Swift Q2_0 hidden (#171); the earlier data folder and nested Strata-data move into --data-dir (#198); whole shards without a finish mark count (#173) 2026-09-30 10:54:45 +02:00
Niko1221 fa59b3f3a9 server: /status needs the API key and forgets the answer's end (#212); constant-time key check, an explicitly empty key refuses to start (#213); an engine exit quotes its last log line (#215); tool calls end at their own </parameter> and </tool_call>, so values may contain them (#210) 2026-09-30 10:54:45 +02:00
Niko1221 eef241178c engine: the expert cache reserves the draft head's VRAM (0.1.27's CJK subset came out of the 700 MiB reserve, #199); a clear message when no VRAM is left for the cache (#174); the prompt's verify-window stage has its own label (#217) 2026-09-30 10:54:45 +02:00
Oluwabori Olaleye 7980d3719f Expert cache on WDDM: 1 GiB steps before quarter steps (~4 GiB more cache on a 32 GB card); Ctrl+C stops the server on Windows; no stack trace when a client hangs up (#153)
Squashed from PR #153.
2026-09-30 10:46:55 +02:00
GioStrives 0425829226 verify: cudaFuncGetName only with CUDA 12.3+ (an sm_75 build with CUDA 12.0 failed)
It names kernels only for the STRATA_VERIFY_NODES debug dump.
2026-09-30 10:46:55 +02:00
Pravesh Khatana 481b69246f fix(engine): clear stale error/cancellation state between requests and before prompt prefill
When a request is cancelled during prompt reading (e.g. client disconnect
or timeout), err is set to 'cancelled'. Because err is declared in main()
and was never cleared between requests, any subsequent request in v0.1.26+
evaluated 'if (!e.empty())' in sp.on_chunk on its first chunk, immediately
failing with 'strata serve: cancelled' and terminating the engine with code 1.

Clearing err at the start of each request, before prompt segment runs,
and on entry to Prefill::run prevents state pollution across requests.
2026-09-30 10:46:55 +02:00
Niko1221 99b40973c6 Engine 0.1.27: CJK answers faster (the draft subset with Chinese, Japanese and Korean), RTX 20 cards, the HIP build without CUDA headers; setup requires it 2026-09-29 22:58:59 +02:00
Niko1221 e227683eb9 data/draft_vocab.bin: the draft subset with all Chinese, Japanese and Korean tokens (40,525 -> 106,299 ids; #137). Q2_0 on CJK answers +15-38% decode, English -1-2%, the same expert slots on 12 GB 2026-09-29 22:58:59 +02:00
Niko1221 d62579d482 RTX 20 in the docs and setup's texts; the ready-made image encoder is compiled here when it has no code for the card (BUILD.json vision_archs) 2026-09-29 22:58:59 +02:00
Niko1221 717971d33b tools/draft_vocab.py: the draft head's token subset, with whole scripts added (#137); setup replaces an older shipped subset at setup and at start, a hand-made one is kept 2026-09-29 22:26:45 +02:00
Niko1221 d92c2a3034 setup: AVX2 from the CPU itself when Windows says no (#159); the engine is compiled again for a card it has no code for, at setup and at start (#128) 2026-09-29 22:23:55 +02:00
Niko1221 ed01b4de03 server: only the image markers the template writes are images; the same text in a message stays text (#150) 2026-09-29 22:23:55 +02:00
Hozák Martin ddfb98a6ea Turing port: run on RTX 20 (sm_75) and newer
Rebased onto main (0.1.22): the portable pre-sm_80 QSA scorer and the sm_80
guard on the tensor-core prompt attention are already in main, so this is
the rest of the port.

- the runtime floor: device_info refuses anything older than compute
  capability 7 (main's runtime check still requires sm_120, so a binary
  built for sm_75 would never start),
- fused_gr: the down kernel carries the tokens in slices that fit the
  card's shared-memory opt-in (Turing, 64 KB: 6-token slices; 4 tokens when
  the card reports no opt-in, under the 48 KB default), merged on top of
  main's per-device opt-in - full 8-token windows no longer fail on Turing
  with "invalid argument",
- the setup/CMake floor is 7.5 (RTX 20 / 30 / 40 / 50) without an
  experimental flag: CMake's arch refusals, setup.py's gpu_problem and the
  GPU hints and error texts,
- docs/MULTI_GPU.md says what a Turing card actually runs.

Verified end-to-end on RTX 2070 (sm_75, 0.1.20 base): model loads, OpenAI/
Anthropic API serving works, greedy output identical to the sm_120 build.

Merged (PR #87) with two changes: the runtime floor is 7.5 exactly (major*10+minor < 75; the PR let Volta 7.0/7.2
through), kept beside main's HIP check; and a card reporting no shared-memory opt-in slices the tokens to what fits the
48 KB default for its own tile (4 tokens on CUDA, all 8 with HIP's smaller tile, so gfx1100 keeps one launch).
2026-09-29 22:23:41 +02:00
samuelishida c81819a87c build: make qsa_prompt_attn.cu compile on HIP-only toolchains
The tensor-core prompt-attention kernel is already refused at runtime on AMD
(line 695), but the file unconditionally included <math_constants.h> for
CUDART_INF_F, which is not present on a ROCm-only build. Replace it with the
portable INFINITY macro from <cmath> so the HIP build succeeds without a CUDA
 toolkit installed.
2026-09-29 22:19:51 +02:00
Niko1221 4832dccd09 docs: speed tables for engine 0.1.26 (prompts 8-28% faster at 32K-128K than 0.1.22) 2026-09-29 22:09:44 +02:00
Niko1221 78db15b630 docs: the low-RAM mode 2026-09-29 21:23:21 +02:00
Niko1221 cfc73be8b9 Low-RAM mode: --mmap-experts works with native (IQ) packs built with experts.bin; setup uses it when the model's experts do not fit the RAM beside the OS (a 32 GB PC with a big GPU: the experts come from the file cache, the GPU holds the most-used) 2026-09-29 20:35:16 +02:00
Niko1221 2f8d9be954 Engine 0.1.26: prompts up to 32K 4-10% faster (the draft layer's prompt pass in batches); setup requires it 2026-09-29 20:01:05 +02:00
Niko1221 c9077a7669 setup: a PC with NVIDIA and AMD cards asks which to use (a first run took NVIDIA without mentioning the Radeon) 2026-09-29 19:59:11 +02:00
Niko1221 a402eefdd1 E-9: the draft layer's prompt pass in batches through the prompt path's kernels (its Q8_0 matrices as Q8_1 x Q8_0 MMQ, the drafter's own integer dot products): 0.9 s -> 0.07 s of a 32K prompt, the same draft acceptance; STRATA_MTP_BATCH=0 switches back 2026-09-29 19:59:11 +02:00
Niko1221 d4e047660b docs: AMD through setup (./setup.sh --backend hip), the k8v4 KV option 2026-09-29 19:17:37 +02:00
Niko1221 cb5ea4acc8 kv_hybrid_parity: on HIP the tensor-core prompt attention refuses every pool (CUDA-only), which is the expected path 2026-09-29 19:15:16 +02:00
Niko1221 922d1408b6 Engine 0.1.25: faster prompts (expert grouping off the copy engine, fused hyper-connection kernels), AMD HIP backend (experimental), --kv k8v4; setup requires it 2026-09-29 19:12:10 +02:00
Niko1221 86c904ca42 Revert "E-8: the draft layer's prompt pass in groups of 8 rows (was max_t = 6): -6% of its time, the same K/V and output"
This reverts commit 45d857c7cf.
2026-09-29 18:41:34 +02:00
Niko1221 45d857c7cf E-8: the draft layer's prompt pass in groups of 8 rows (was max_t = 6): -6% of its time, the same K/V and output 2026-09-29 18:22:21 +02:00
Niko1221 d07d04fcae CMake: kv_hybrid_parity through the backend's runtime target (CUDA or HIP) 2026-09-29 18:10:19 +02:00
Niko1221 36ef9d6e6b Merge the AMD HIP backend (PR #121 by Konstantinos Korres, rebased onto 0.1.24) and its setup support 2026-09-29 18:09:50 +02:00
orange 45bd2d0a4d qsa_prompt_attn: mode 3 for hybrid K8V4 - int8 K, V dequantized from q4 at gather
The generic prompt kernel now templates K and V element types separately, so
mode 3 is mode 1's INT8 K path (codes, scales, i8x2 MMA conversion) composed
with mode 0's fp16 V path: the row's q4_0 blocks dequantize straight into the
fp16 V row at gather. The caller's output Hadamard is unchanged. Pure Q4_0
pools still refuse (whole-format q4 stays on the old kernel).

The v2 cp.async int8 kernel (launch_i8) is untouched; mode 3 uses the generic
launch<>, as fp16 does. Parity's [5/5] flips from a refusal check to a numeric
check against the dequant reference (accuracy-level, not bitwise, like the
kernel's own int8 variant).
2026-09-29 18:09:42 +02:00
orange 3597c47ff8 Hybrid K8V4 KV cache: INT8 K + Hadamard-rotated Q4_0 V (--kv k8v4)
K stays INT8 (unrotated - the attention scores stay exactly int8's), V uses the
rotated q4_0 of kv_q4.hpp: 816 B per cell vs int8's 1,056. The K/V quantization
asymmetry is the point: K enters through the softmax scores, V only through a
softmax-weighted average (llama.cpp's q8_K/q4_V hybrids reason the same way).

No new kernels: the append/gather entry points already select K/V by blockIdx.z
and take separate K/V pointers, so the hybrid composes the existing validated
q8/q4 kernels with the unused half's lanes folded onto the used pool (a
bit-identical duplicate write). The fused attention gets KV_MODE 3 (K-side int8
loader, V-side q4); 0.1.22's tensor-core prompt path (qsa_prompt_attn) refuses
the hybrid pools through its !v_q check and the old kernel runs - covered by
the parity test. The MTP drafter stays INT8 (mtp.cpp toggles the format globals
around its state creation, any ring shape); qsa_kv_format exits on a hybrid
state rather than misreporting a layout (the kernels' own unsupported-geometry convention); the hybrid appends pass nullptr for
the host mirror (mode 0 has none). setup.py excludes k8v4 from the whole
auto-streaming decision (the engine refuses --kv-resident with it).

kv_hybrid_parity: bitwise append/gather through a non-identity page table,
step and batched mode-3 attention vs host references (<= 1.1e-07), and the
qsa_prompt_attn refusal - all at machine precision; kv_q8/kv_q4 parity unchanged.

Measured on a RTX 3090, Coder IQ1_M, 118.75k-token prompts, temp 0: decode
82-97 t/s (int8: 75; the band is draft-acceptance variance), prefill unchanged
~1,130-1,170 t/s, 26-needle seed 42 identical to int8's tokens, KV 1.39 GB at
131k (int8: 1.80). At --max-context 204800 on 24 GB: 1,024 t/s prefill,
82-91 t/s decode, 25/26 needles at 197.9k.
2026-09-29 18:09:42 +02:00
Niko1221 3945aac617 F-2: a half's hyper-connection write fused with the next half's norm (bit-identical; with F-1 +4.0% at 32K, +3.3% at 128K) 2026-09-29 17:56:08 +02:00
Niko1221 2a6bbc7304 F-1: the hyper-connection read without its FP32 copy of the normalized rows (gr_mix recomputes them, bit-identical; +1.1% at 32K, +1.7% at 128K) 2026-09-29 17:45:17 +02:00
Niko1221 5cb4ff283d AMD: hipBLASLt table for 100200 (the pinned ROCm), used by setup's configs (+42-59% prompt speed on the 7900 XTX); server: a config's env reaches the engine 2026-09-29 17:26:20 +02:00
Niko1221 1b3bb8f808 prompt path: the per-layer expert grouping tables through mapped pinned memory, off the copy engine (+3.9% at 32K, +4.5% at 128K, bit-identical) 2026-09-29 15:37:52 +02:00
Niko1221 9065ae0b2d AMD: card name, setup headline, docs for ./setup.sh --backend hip 2026-09-29 15:27:22 +02:00
Niko1221 10d5cad82b setup (experimental): AMD RX 7900 XT/XTX on Linux - KFD detection, ROCm from TheRock wheels into .venv, HIP engine compiled here; server: HIP_VISIBLE_DEVICES 2026-09-29 15:22:22 +02:00
Niko1221 8578e6da6f docs (AMD): the settings after the rebase onto 0.1.24 2026-09-29 15:17:50 +02:00
Niko1221 504dc71aa0 HIP test: the batched indexer append within its contract (p0 + n <= max_cells) 2026-09-29 15:12:52 +02:00
Niko1221 32fe13bac9 HIP: build main's post-0.1.21 code (trap, device attribute, window-graph listing and GPU stamp); Windows NOMINMAX 2026-09-29 15:05:55 +02:00
Niko1221 520883e049 HIP tests: main's batched indexer append and I/O pool (C-2, C-4) replace the PR's duplicates 2026-09-29 14:36:32 +02:00
Niko1221 78a5bf70dd HIP: keep the D-1 attention and QSA select tensor-core paths CUDA-only (AMD takes the portable kernels) 2026-09-29 14:34:31 +02:00
Konstantinos Korres fa42f31dbd AMD HIP backend for gfx1100 (PR #121, squashed onto its 0.1.21 base) 2026-09-29 14:33:45 +02:00
Niko1221 aaeb5a4120 Engine 0.1.24: long prompts faster (the QSA selection on tensor cores); setup requires it 2026-09-29 13:53:04 +02:00
Niko1221 2f9c38f0fe QSA select: block scores on tensor cores, top-k keys in registers (+14.6% prompt at 128K)
The prompt path's selection at long context was 18% of a 128K prompt. Two changes:
  * block scores as a GEMM on tensor cores: rows (query, indexer head) x blocks, K = 128, relu per head summed
    in the warp kernel's order; 3xTF32 (hi*hi + hi*lo + lo*hi) keeps FP32-level accuracy; the tail block (the
    `dead` key, +1e9) keeps the warp kernel's arithmetic. sm_80+ (TF32 MMA), shared-memory opt-in per device;
    STRATA_SELECT_OLD=1 (or an older card): the warp kernel. Not bitwise (another summation order).
  * top-k with each query's keys read once into registers (1,024 threads, up to 33 blocks each: contexts up to
    ~135K), per-warp histograms, block scans - the same selection rule, identical ids (exact; decode uses it
    too). STRATA_TOPK_OLD=1: the original kernel.
5070, 256 queries (src/kernels/qsa_select_bench.cpp): 32K 1.18 -> 0.36 ms, 128K 4.68 -> 1.33 ms; synthetic selections
identical 256/256, the register top-k identical to the reference.
Quality gate (Q2_0, vs STRATA_SELECT_OLD=1 + STRATA_TOPK_OLD=1): needles 8K/16K/32K x 5 depths 15/15 with both;
16K continuation 32/32 identical, KL 0.005; 32K top-1 same, KL 0.03 (the size of any FP32-level change, cf. D-1).
Prompt: 32K 1,742 -> 1,797 tok/s (select 897 -> 365 ms), 128K 1,608 -> 1,843 tok/s (select 14.6 -> 4.2 s),
time to first token 82.7 -> 72.3 s.
2026-09-29 13:40:49 +02:00
Niko1221 d17fcba9f6 prefill timing: the GDN layer split into projections in, conv+gates, recurrence, projection out 2026-09-29 13:40:49 +02:00
Niko1221 d32101a617 Speed tables: prompts with engine 0.1.22 (bench/results/2026-09-29-speed-0122) 2026-09-29 13:38:54 +02:00
Niko1221 3a7f188578 Engine 0.1.23: image requests honor sampling, 8 GB cards start, batched verify window, llama.cpp endpoints; setup requires it 2026-09-29 13:10:38 +02:00
Niko1221 66f3f30ba0 setup: a size that needs more RAM than the PC has is a warning and a question, not a stop (#125)
It says what happens (the experts must stay in RAM, so the OS pages part of them: much slower, maybe no start) and
asks, "no" by default, so an unattended --yes install still stops. Thanks @FatheredPuma81.
2026-09-29 12:27:11 +02:00
Niko1221 3b2ced8eb6 The Task Scheduler load hint on Windows only (a slow load elsewhere is the disk) 2026-09-29 12:26:40 +02:00
Niko1221 a3cdb8a2a1 Merge branch 'pr-126' into next-023 2026-09-29 12:26:25 +02:00
Niko1221 02c8092acf D-1: the prompt attention's shared-memory opt-in per device (a layer split with fp16 KV failed on the second card)
The opt-in was set once per process, but it is a per-device setting. The int8 kernel fits the default 48 KB; the
fp16 one (KV of contexts up to 8K) needs 54 KB, so on the second card of a split its launch failed.
2026-09-29 12:06:58 +02:00
Niko1221 21482dfb09 CMake: dequant_bf16_test only with CUDA (a CPU-only configure with the tests failed, #93)
Thanks @Avicennasis.
2026-09-29 11:50:33 +02:00
Niko1221 1977f5952b Merge PR #109 (q8atnight): batch the verify window's per-token kernels
Resolved against 0.1.22: E-6's device-plan waits and copies kept (the row copy of the CPU share is used when the
device plan is off - bit-identical, +0.0 on the GPU rows as the pool leaves them); the multi-GPU per-device
shared-memory opt-in and stage pool callback kept. Left out: the session-wide --pcie-frac 0 skip of the PCIe path,
because a request can ask for a PCIe share (pcie_frac=, used by --calibrate) and the window graph is captured once.
2026-09-29 11:41:04 +02:00
Niko1221 58f6a1aa70 serve: with no expert cache (a full 8 GB card) prompts are read in 1,024-token chunks; a clear message when the prompt buffers don't fit (#85)
--prefill auto sized the chunk only when there were cache slots to lend; with none the buffers stayed at 8,192
tokens and a full card stopped at start with "device buffers ... do not fit". Thanks @sinand99.
2026-09-29 11:39:32 +02:00
Niko1221 12ef286562 Image requests honor sampling (temperature, top_p, top_k, penalties, seed) like text (#75)
The server sent only cvec= with GENI and the engine skipped every other key, so a request with an image decoded
greedily whatever the client asked. Both now pass the text path's keys. Defaults unchanged (greedy unless set).
Thanks @jleaders for pinning it down.
2026-09-29 11:39:03 +02:00
Niko1221 d2a70e8321 serve tests: the page links its files relatively since #82 2026-09-29 11:36:28 +02:00
Niko1221 b839e26b78 Merge branch 'pr-118' into next-023 2026-09-29 11:34:45 +02:00
Niko1221 9aa0362f10 Merge branch 'pr-92' into next-023 2026-09-29 11:34:45 +02:00
Niko1221 9d2309e4e0 Merge branch 'pr-91' into next-023 2026-09-29 11:34:45 +02:00
Niko1221 762daf658a Merge branch 'pr-89' into next-023 2026-09-29 11:34:45 +02:00
Niko1221 0b0dd51362 Merge branch 'pr-82' into next-023 2026-09-29 11:34:45 +02:00
Niko1221 1d10562097 Merge branch 'pr-81' into next-023 2026-09-29 11:34:44 +02:00
Niko1221 946bafaebc Merge branch 'pr-104' into next-023
# Conflicts:
#	serve/server.py
2026-09-29 11:34:44 +02:00
Niko1221 8d4790a953 Merge branch 'pr-113' into next-023 2026-09-29 11:33:57 +02:00
Niko1221andmikicvi 81726d35a5 serve: timings carry the speculative draft counts (draft_n, draft_n_accepted), from PR #83
Co-Authored-By: mikicvi <[email protected]>
2026-09-29 11:33:56 +02:00
Niko1221 2e1efbca31 Merge branch 'pr-107' into next-023
# Conflicts:
#	serve/server.py
2026-09-29 11:32:59 +02:00
Niko1221 24a384da06 Merge branch 'pr-122' into next-023 2026-09-29 11:32:21 +02:00
Zauberio 027986e8ab Windows: name the Task Scheduler load throttle (24x) and how to fix it
The expert-arena load runs at ~0.05 GiB/s (13-14 min to ready) when the serve
is started from Task Scheduler with its registration defaults (Priority 7 /
Below normal, least-privilege token), versus 1.4-1.5 GiB/s (~35 s) from a
terminal or SSH - same binary, args and cache state.

- docs/DETAILS.md: measured table + the two registration settings that fix it
- src/program/generate.cpp: when load_gib_per_second() < 0.2 GiB/s, print a
  hint naming this cause under the load line
2026-09-29 11:18:54 +02:00
Hendrik PolczynskiandCodex 08f8d591da serve: add llama.cpp-compatible discovery endpoints
Expose loaded-model metadata through /models and /v1/models.
Add /props and single-slot /slots responses with existing API-key
enforcement and regression coverage.

Validation: all 30 server tests pass. Pi 0.87.1 discovered the loaded
model with 262144-token context and thinking support, and completed
a chat smoke test successfully.

Fixes #117.

Co-authored-by: Codex (gpt-6-astra-medium) <[email protected]>
2026-09-29 10:45:33 +02:00
Vilim Mikic 2c1292f688 Merge branch 'main' into web-relative-paths 2026-09-29 09:01:59 +01:00
Vilim Mikic bec183e28f Merge branch 'main' into sm80-runtime-check 2026-09-29 09:01:50 +01:00
Marmaduke Woodman 7b3087a82f serve: routing traces from the live server (--dump-routing under --serve)
Two gaps kept the routing trace one-shot-only:

- drive_pool_multi, the verify-window dispatch the serve path uses for every
  decode call, had no trace hook; --dump-routing under --serve opened the file
  and wrote nothing. Records now go out per token in drive_pool's format
  (layer, k, ids, weights). The multi dispatch fuses the router weights into
  the kernel and does not surface them, so records carry unit weights:
  make_profile.py ranks pairs by routed frequency, which is the signal that
  matters; a one-shot run still records true weights.
- the trace sat in a stdio buffer until teardown, so a crash lost the whole
  session's data. It now flushes at every request's DONE.

Verified live: a serve-mode trace grows per request, parses cleanly in
make_profile.py's format (all 48 layers, records per decode position), and
captures real multi-session traffic for expert-profile building.
2026-09-29 07:32:22 +00:00
Yunado 8989030db5 serve: keep image parts in tool/assistant messages
Agent clients put screenshots in tool results; flattening non-user messages to text made the model blind to them. The template and images_of already handle them; only the conversion was too strict.
2026-09-29 02:41:30 -04:00
architectds c4d549aa25 serve: report the cached prompt tokens, llama.cpp timings and GET /v1/status
The conversation cache's reuse is kept per request (GET /metrics shows it),
but the APIs did not report it, so clients showed a cached prompt as 0%:

- OpenAI: usage.prompt_tokens_details.cached_tokens, streamed and not.
- Anthropic: the final usage splits input_tokens from
  cache_read_input_tokens (message_start can only know the whole prompt).
- llama.cpp's `timings` block on the last chunk and on a whole reply
  (prompt_n, cache_n, prompt/predicted ms and per second), from the
  engine's own clock: what llama.cpp's clients already show as speed.
- GET /v1/status (with the API key when one is set): the model and its
  window, images, the APIs, whether a request is running, the last
  request's timings and the GPU and RAM from the telemetry, for a
  front-end that polls its server rather than guessing.

An engine without a clock (MockEngine) reports no timings and 0 cached.
Tests: serve/test_server.py UsageAndStatus.
2026-09-29 00:28:37 -05:00
xyzzing ef14b6ca69 The Monitor's live tok/s is a rate over the last seconds, not the mean since the first token
live.tok_s returned `generated / (now - first_token)`: a cumulative mean whose
first sample is 1/elapsed.  Against an engine paced at 25 tokens/s the Speed
card, the header pill and the tok_s sparkline read 27,235.7 tok/s at that first
sample - about 1090x the real rate - and then undershot for the first second of
every answer (one token counted over up to a second of elapsed time).  Because
the sparkline scales to the maximum of its 60-sample ring, one such sample also
flattened the Speed graph for a minute.

tok_s is now the rate over the last RATE_WINDOW_S (2 s), with the mean so far
reported only while a request is younger than RATE_MIN_SPAN_S (0.25 s) and its
span floored there, so no sample can diverge.  The old formula is kept as
_tok_s_mean() and still reported (live.tok_s_mean, status.tokens_per_s_mean).

The same request fixes a second mis-attribution: a request that ended without a
DONE line (engine death, error, client disconnect) recorded the PREVIOUS
request's generated/decode_ms as its own decode_tok_s.  The engine's counters
are now trusted only when its DONE replaced the object captured at request
start, and the row carries the engine's own token count as engine_generated.

Tests: serve/test_server.py::LiveRate paces a mock engine and polls GET /metrics
at 10 ms.  Both cases fail without the server.py change (990.0 tok/s recorded
for a request that never finished) and pass with it; the rest of the suite is
unchanged at 28 tests, OK.  The engine's own per-request counters - what the
Speed card shows for "last request" - are untouched, and the server's progress
and summary log lines keep their whole-request mean on purpose.
2026-09-29 13:26:28 +08:00
Andy 4a50d4ce8f decode: batch the verify window's per-token kernels (bit-identical); skip the PCIe path with --pcie-frac 0; decode timing/profile switches 2026-09-29 05:55:18 +04:00
Léon Avic Simmons ab541eb558 strata_pack verify: read back the source sha256 the manifest recorded
build writes source.shard1_sha256 / shard2_sha256 into manifest.json (unless --skip-hash, which is how
setup builds), and nothing read them back: src/, serve/, setup.py, tools/ have 0 readers. A pack
verified against a different shard than it was built from reported tensor MISMATCHes, or passed under
--limit.

A full verify (no --limit) now hashes the shard it is handed and fails naming both digests when they
differ; a manifest without the field, or a --limit run, is unchanged. Shard 2's hash is recorded but not
checked here: verify is not handed that file. tools/test_shards.py: a matching hash passes, a wrong one
fails naming both, an absent hash or --limit skips. On main the wrong-hash case returns 0.
2026-09-28 17:58:24 -04:00
Léon Avic Simmons 392fc17287 gguf reader: a duplicate tensor name is refused at open, naming the tensor and the file
find() is first-match over the tensor directory, so a GGUF that carries one name twice resolves it to
whichever entry came first; the other tensor's bytes are never read and nothing says so. GGUF has no
index that could arbitrate between the two, so refusal is the whole fix: parse() keeps the names it has
seen and throws on the second sighting ("GGUF: duplicate tensor name 'blk.0.attn_q.weight' in <path>"),
as llama.cpp's gguf_init_from_file does.

gguf_reader_test (STRATA_BUILD_TESTS; no model, no GPU) writes a two-tensor GGUF v3 by hand: distinct
names open and find() sees both; the same name twice is refused and the error names the tensor and the
file. On main the second file opens: 3 of 4 checks fail.
2026-09-28 17:58:24 -04:00
Léon Avic Simmons 896586e365 setup: a missing or short shard is named with its numbers before anything reads it
setup checked a shard by presence only, and a download by Content-Length only: a shard copied short into
--gguf-dir, a .part renamed by hand or a download an older setup finished without a mark passed as a
model file, and the failure came later from the packer or the engine, at the first tensor past the end,
naming neither the file nor the shortfall.

check_shards() reads each shard's header (not its data) and compares the file's length with what its own
tensor directory needs: "m-00001-of-00002.gguf is short: 1,234 of 1,274 bytes (40 missing)"; a header
that does not parse is refused by name; a missing file stays "missing <path>". The download's "could not
finish" line now says the bytes on disk and the bytes the server announced.

tools/test_shards.py (unittest over a minimal GGUF written by hand; no download): whole shards pass; a
missing, a short and a header-truncated shard each stop setup with the file named.
2026-09-28 17:58:24 -04:00
Denis Chirkov 2f78d84362 mtp: bulk-read the drafter shards as well
The drafter loader had the same defect as the expert arena: read_file() pulled the whole
dense.bin through std::ifstream, and the 512-expert loop read 64 MiB chunks through the
same 4095-byte-per-call path. Same fix (fopen/fread, _fseeki64/_ftelli64, short-read
check), so the drafter stage stops paying per-read overhead.
2026-09-28 23:17:31 +03:00
Denis Chirkov edeb92848c loader: read the expert arena in bulk instead of std::ifstream
On MSVC, std::ifstream::read does not issue one fread per call: basic_filebuf::xsgetn
loops over fread(_Ptr, 1, 4095, _Myfile) (__msvc_filebuf.hpp, _Read_size = 4095).
A 44.7 GB expert arena therefore costs ~11.4 million 4095-byte reads instead of a few
thousand bulk reads, and the load is dominated by per-call overhead.

Read the arena with fopen/fread, an explicit 64-bit seek (_fseeki64) and a short-read
check that fails the load (LoadStats.ok/error) instead of silently returning partial
data. Behaviour on Linux/glibc is unchanged in substance: same bytes, same call sites.
2026-09-28 23:17:30 +03:00
mikicvi 5c5a059574 Web app works behind path-prefixed reverse proxies
The page referenced its stylesheet, script, sprite, fonts and every API call
with absolute paths (/web/app.css, fetch("/health"), url("/fonts/...")).
Behind a reverse proxy that mounts the app under a sub-path - llama-swap's
/upstream/<model>/, an nginx subpath - those resolve to the proxy root and
404: an unstyled page stuck at Connecting, or a Monitor that polls /metrics
at the proxy root and reports 'Server not reachable'.

Make every same-origin reference relative:

- index.html: (href|src)="/web/... -> web/...
- app.js: fetch("/... -> fetch("..., plus the endpoint strings used outside
  a plain fetch(" call - the Monitor's ternary fetch(reqShowAll ?
  "/metrics?requests=all" : "/metrics") and the SPRITE constant - because
  the quote does not always sit right after 'fetch('
- tokens.css: url("/fonts/... -> url("../fonts/... (fonts have their own
  top-level /fonts/ route, one level above the CSS)

Standalone at / resolves the same, so nothing changes there.

Verified through llama-swap's /upstream/<model-id>/ prefix in a browser:
page, stylesheets, fonts, sprite icons, /health, /settings, /metrics, /mcp
and a streamed /v1/chat/completions POST all 200; the Monitor tab connects
and streams live telemetry. Same checks pass at / standalone.
2026-09-28 19:25:59 +00:00
mikicvi 941f671d65 Accept sm_80+ GPUs at runtime, matching the build guard and the README
The CMake arch guard already lets RTX 30 (sm_86) / 40 (sm_89) build - its own
comment says the kernels need sm_80 or newer (tf32 mma, bf16 math) - and the
README lists RTX 30/40/50 as supported. But device_info() still refused
anything with cc_major != 12, so a working Ampere/Ada build died at startup
with 'Strata targets sm_120 only'. Relax the runtime check to the kernels'
real floor (compute capability 8.0) and align the stale 120-only comments.

Verified on RTX A2000 12GB + RTX 3080 10GB (both sm_86), CUDA 12.0, gcc-13,
engine 0.1.20 serving daily: TG 21-24 t/s flat 1k-128k, 29.6 t/s code-gen at
81% draft acceptance, PP 375/445 @8k/32k, 8/8 needle retrieval at 113k.
2026-09-28 19:14:51 +00:00
181 changed files with 19586 additions and 1035 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Keep the image to the source only: everything generated is re-created on
# first start (inside the container or on the /data volume).
.git
# local Python environment (rebuilt in the image)
.venv/
__pycache__/
*.pyc
.pytest_cache/
# setup.py outputs and downloads (engine, packs, models, logs)
/engine/
/build/
/build*/
/dist/
/packs/
/mtp/
/models/
/Strata-data/
third_party/llama.cpp/
third_party/*.zip
*.done
*.log
strata-*.json
strata-*.txt
run-*.sh
run-*.bat
+3 -1
View File
@@ -18,6 +18,8 @@ __pycache__/
# plan v0.3 P1 (repository hygiene, review E1): profiler captures and large diagnostic dumps stay out of git.
*.nsys-rep
# Pi harness metadata (local session state, not project source)
*.sqlite
*.npz
*.occ1
@@ -26,7 +28,7 @@ td.log
# created by setup.py on the user's machine
/.venv/
/build/
/third_party/llama.cpp/
/third_party/llama.cpp
/models/
/packs/
/mtp/
+355 -119
View File
@@ -4,11 +4,11 @@
# `strata-pack` are added when their sources exist - a target with no sources would make the build graph
# claim something the tree does not have, which is worse than an absent target.
#
# CUDA targets arrive in Phase 2 and this build REJECTS any architecture other than 120: the engine is
# written against sm_120 (RTX 5000 / Blackwell) and silently building for an older arch would produce a
# binary that runs and is wrong about its own performance, which is the worst of both outcomes.
# CUDA targets arrive in Phase 2. The kernels need sm_80 or newer (tf32 mma, bf16 math) and the guard
# 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.22 LANGUAGES CXX)
project(strata VERSION 0.1.30 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,14 +33,36 @@ 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_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
if(STRATA_PREFILL_MMQ AND NOT STRATA_ENABLE_HIP)
message(FATAL_ERROR "STRATA_PREFILL_MMQ requires STRATA_ENABLE_HIP=ON")
endif()
if(STRATA_ENABLE_CUDA AND STRATA_ENABLE_HIP)
message(FATAL_ERROR "STRATA_ENABLE_CUDA and STRATA_ENABLE_HIP are separate backends; enable only one")
endif()
if(STRATA_ENABLE_HIP)
include(cmake/hip_backend.cmake)
endif()
# The test suite and the micro benchmarks live in tests/ and bench/micro/, which the published source leaves out.
set(_strata_tests_default OFF)
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/bench/micro)
set(_strata_tests_default ON)
endif()
option(STRATA_BUILD_TESTS "Build the test suite" ${_strata_tests_default})
option(STRATA_BUILD_CONVERSATION_TESTS "Build standalone conversation cache tests" OFF)
option(STRATA_WERROR "Treat compiler warnings as errors" OFF)
if(STRATA_BUILD_CONVERSATION_TESTS)
enable_testing()
add_executable(conversation_cache_test src/core/conversation_cache_test.cpp)
target_include_directories(conversation_cache_test PRIVATE include)
add_test(NAME conversation_cache_test COMMAND conversation_cache_test)
add_executable(conversation_memory_test src/core/conversation_memory_test.cpp src/core/conversation_memory.cpp)
target_include_directories(conversation_memory_test PRIVATE include)
add_test(NAME conversation_memory_test COMMAND conversation_memory_test)
endif()
if(STRATA_ENABLE_CUDA)
enable_language(CUDA)
# `enable_language(CUDA)` gives the .cu compile rules but NOT the imported targets, so `CUDA::cudart` has to
@@ -51,18 +73,36 @@ if(STRATA_ENABLE_CUDA)
endif()
foreach(_a IN LISTS CMAKE_CUDA_ARCHITECTURES)
string(REGEX REPLACE "-.*$" "" _base "${_a}") # 120-real / 120-virtual are still 120
# Plan v0.3 P6: developed and measured on sm_120 (RTX 50); the kernels need sm_80 or newer (tf32 mma in
# the attention scorer, bf16 math), so RTX 30 (86) and RTX 40 (89) build too. Older cards are refused.
# Turing port: developed and measured on sm_120 (RTX 50); the tf32 mma in the QSA scorer has a
# portable fp32-FMA fallback, so RTX 20 (75), 30 (86), RTX 40 (89) and RTX 50 (120) build too.
# Pre-sm_75 is refused.
if(_base STREQUAL "native" OR _base STREQUAL "all" OR _base STREQUAL "all-major")
continue()
endif()
if(_base LESS 80 AND NOT STRATA_EXPERIMENTAL_SM75) # the A/B of a Turing card as a layer-split stage
# STRATA_EXPERIMENTAL_SM60: a Pascal (sm_60, GP100-class) build. It sits below every architecture
# these kernels were written for, so two intrinsics are replaced for it - `__dp4a` (6.1+) and
# `__nanosleep` (7.0+) - in include/strata/kernels/dp4a.hpp, which is bit-exact for the first and
# merely a shorter pause for the second. RTX 20/30/40/50 cards must use the release engine instead.
if(_base LESS 75 AND NOT STRATA_EXPERIMENTAL_SM60)
message(FATAL_ERROR
"Strata needs an NVIDIA GPU of compute capability 8.0 or newer (RTX 30 / 40 / 50 series), but "
"CMAKE_CUDA_ARCHITECTURES is '${CMAKE_CUDA_ARCHITECTURES}'.")
"Strata needs an NVIDIA GPU of compute capability 7.5 or newer (RTX 20 / 30 / 40 / 50 series), but "
"CMAKE_CUDA_ARCHITECTURES is '${CMAKE_CUDA_ARCHITECTURES}'. (A Pascal build is "
"-DSTRATA_EXPERIMENTAL_SM60=ON; upstream does not support it.)")
endif()
# #220 / #224: RTX 50 engines built with CUDA 12.8 crashed in the prompt path on Linux (13.0 and newer did not)
if(_base GREATER_EQUAL 120 AND CMAKE_CUDA_COMPILER_VERSION VERSION_LESS 13.0)
message(WARNING "Building for sm_${_base} (RTX 50) with CUDA ${CMAKE_CUDA_COMPILER_VERSION}: engines built with "
"CUDA 12.8 crashed on long prompts on these cards (issues #220, #224). Use CUDA 13.0 or newer.")
endif()
endforeach()
message(STATUS "Strata: CUDA enabled, arch ${CMAKE_CUDA_ARCHITECTURES}")
set(_strata_gpu_runtime_target CUDA::cudart)
set(_strata_gpu_blas_target CUDA::cublas)
set(_strata_gpu_include_directories ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
elseif(STRATA_ENABLE_HIP)
set(_strata_gpu_runtime_target strata_hip_runtime)
set(_strata_gpu_blas_target roc::hipblas)
set(_strata_gpu_include_directories ${STRATA_HIP_COMPAT_INCLUDE_DIR})
endif()
# Warnings live on an INTERFACE target the project's own code links, NOT in add_compile_options: applied
@@ -78,11 +118,14 @@ target_compile_options(strata_warnings INTERFACE
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:MSVC>>:/utf-8>
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Wall>
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Wextra>
$<$<AND:$<COMPILE_LANGUAGE:CUDA>,$<CXX_COMPILER_ID:MSVC>>:-Xcompiler=/W4>)
$<$<AND:$<COMPILE_LANGUAGE:CUDA>,$<CXX_COMPILER_ID:MSVC>>:-Xcompiler=/W4>
$<$<COMPILE_LANGUAGE:HIP>:-Wall>
$<$<COMPILE_LANGUAGE:HIP>:-Wextra>)
if(STRATA_WERROR)
target_compile_options(strata_warnings INTERFACE
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<CXX_COMPILER_ID:MSVC>>:/WX>
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Werror>)
$<$<AND:$<COMPILE_LANGUAGE:CXX>,$<NOT:$<CXX_COMPILER_ID:MSVC>>>:-Werror>
$<$<COMPILE_LANGUAGE:HIP>:-Werror>)
endif()
# ---- strata_artifact -------------------------------------------------------
@@ -100,6 +143,12 @@ add_executable(strata-dequant src/artifact/dequant.cpp)
target_link_libraries(strata-dequant PRIVATE strata_artifact)
# this file is now nothing but the CLI entry point, and its inherited guard spells that as a "selftest"
target_compile_definitions(strata-dequant PRIVATE STRATA_DEQUANT_SELFTEST)
if(STRATA_BUILD_TESTS)
# the reader refuses a duplicate tensor name at open, naming it (a synthetic GGUF: no model, no GPU)
add_executable(gguf_reader_test src/artifact/gguf_reader_test.cpp)
target_link_libraries(gguf_reader_test PRIVATE strata_artifact)
add_test(NAME gguf_reader_test COMMAND gguf_reader_test)
endif()
# ---- strata_plan ----------------------------------------------------------
# Also header-only. A "planner" that hides state would be one whose decision is not inspectable, and the
@@ -111,17 +160,16 @@ target_link_libraries(strata_plan INTERFACE strata_warnings)
add_executable(strata-plan src/plan/plan_main.cpp)
target_link_libraries(strata-plan PRIVATE strata_plan)
# ---- strata_core (CUDA, Phase 2) ------------------------------------------
# Only built when CUDA is on, so the Phase 1 targets keep working on a machine with no toolkit. The arch
# guard above has already refused anything but sm_120 by the time this runs.
if(STRATA_ENABLE_CUDA)
# ---- strata_core (GPU runtime) ---------------------------------------------
# Only built when a GPU backend is on, so the Phase 1 targets keep working without a toolkit.
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_library(strata_core STATIC src/core/device.cu src/core/pinned.cu src/platform/memory.cpp src/core/graph.cpp src/core/weights.cpp
src/core/layout.cpp)
target_include_directories(strata_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
# CUDA::cudart is PUBLIC because graph.cpp is a HOST translation unit that calls cudaGraphLaunch and
# The selected runtime is PUBLIC because graph.cpp is a HOST translation unit that calls cudaGraphLaunch and
# cudaEventQuery: the .cu sources get cudart through the CUDA language rules, a .cpp does not, and the
# linker error ("unresolved external symbol cudaGraphInstantiate") names the function rather than the cause.
target_link_libraries(strata_core PUBLIC strata_plan strata_warnings CUDA::cudart)
target_link_libraries(strata_core PUBLIC strata_plan strata_warnings ${_strata_gpu_runtime_target})
add_executable(strata-device src/core/device_main.cpp)
target_link_libraries(strata-device PRIVATE strata_core)
@@ -130,13 +178,13 @@ if(STRATA_ENABLE_CUDA)
target_link_libraries(strata-load PRIVATE strata_core strata_kernels)
add_executable(strata-concurrent src/core/concurrent_main.cpp)
target_include_directories(strata-concurrent PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(strata-concurrent PRIVATE strata_core strata_kernels CUDA::cudart)
target_include_directories(strata-concurrent PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(strata-concurrent PRIVATE strata_core strata_kernels ${_strata_gpu_runtime_target})
add_executable(strata-overlap src/core/overlap_main.cpp)
# a .cpp that calls cudaMemcpyAsync itself needs the toolkit headers and cudart explicitly
target_include_directories(strata-overlap PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(strata-overlap PRIVATE strata_core strata_kernels CUDA::cudart)
target_include_directories(strata-overlap PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(strata-overlap PRIVATE strata_core strata_kernels ${_strata_gpu_runtime_target})
# The load test needs the 34 GB artifact, so it is registered ONLY where that artifact exists. A test that
# silently skips is a test that stops running; an absent registration is visible in the configure output.
@@ -200,8 +248,8 @@ if(STRATA_ENABLE_CUDA)
src/platform/direct_file.cpp)
target_link_libraries(strata_kernels PUBLIC strata_core strata_artifact)
# perf-review D-1: the tensor-core prompt attention vs the FP32 kernel and FP64 (GPU, synthetic, no model); with
# the tests, or alone with -DSTRATA_PARITY_PROMPT_ATTN=ON
if(STRATA_BUILD_TESTS OR STRATA_PARITY_PROMPT_ATTN)
# the tests, or alone with -DSTRATA_PARITY_PROMPT_ATTN=ON. CUDA only: HIP compiles the tensor-core kernel out.
if((STRATA_BUILD_TESTS OR STRATA_PARITY_PROMPT_ATTN) AND STRATA_ENABLE_CUDA)
add_executable(qsa_prompt_attn_parity src/kernels/qsa_prompt_attn_parity.cpp)
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)
@@ -209,30 +257,38 @@ if(STRATA_ENABLE_CUDA)
# 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.
set_source_files_properties(src/kernels/cuda/native_mmvq.cu src/kernels/cuda/native_bf16.cu
src/kernels/cuda/native_gr_norm.cu
src/kernels/cuda/native_moe.cu
src/kernels/cuda/native_gdn.cu
src/kernels/cuda/native_gr_postops.cu
src/kernels/cuda/native_router.cu
src/kernels/cuda/native_gdn_preprocess.cu
src/kernels/cuda/native_qsa.cu
src/kernels/cuda/native_rope.cu
src/kernels/cuda/native_ple_postops.cu
src/kernels/cuda/native_flash_attn.cu
src/kernels/cuda/native_qsa_indexer.cu
src/kernels/cuda/native_qsa_score.cu
PROPERTIES COMPILE_OPTIONS "--use_fast_math")
if(STRATA_ENABLE_CUDA)
set_source_files_properties(src/kernels/cuda/native_mmvq.cu src/kernels/cuda/native_bf16.cu
src/kernels/cuda/native_gr_norm.cu
src/kernels/cuda/native_moe.cu
src/kernels/cuda/native_gdn.cu
src/kernels/cuda/native_gr_postops.cu
src/kernels/cuda/native_router.cu
src/kernels/cuda/native_gdn_preprocess.cu
src/kernels/cuda/native_qsa.cu
src/kernels/cuda/native_rope.cu
src/kernels/cuda/native_ple_postops.cu
src/kernels/cuda/native_flash_attn.cu
src/kernels/cuda/native_qsa_indexer.cu
src/kernels/cuda/native_qsa_score.cu
PROPERTIES COMPILE_OPTIONS "--use_fast_math")
elseif(STRATA_ENABLE_HIP)
# Keep Q8_K's multiply and magic-bias rounding as separate FP32 operations on HIP; contraction to an
# FMA changes tie results. The embedding gather also relies on separately rounded operations.
set_source_files_properties(src/kernels/cuda/elementwise.cu src/kernels/cuda/quantize_act.cu
PROPERTIES COMPILE_OPTIONS "-ffp-contract=off")
endif()
set(STRATA_ORACLE_SOURCE_DIR "" CACHE PATH "Pinned llama.cpp source for independent CUDA parity tests")
set(STRATA_ORACLE_BUILD_DIR "" CACHE PATH "Matching existing shared llama.cpp build")
if(STRATA_ORACLE_SOURCE_DIR AND STRATA_ORACLE_BUILD_DIR)
# This oracle is a CUDA ggml build; do not pull CUDA libraries into a HIP build.
if(STRATA_ENABLE_CUDA AND STRATA_ORACLE_SOURCE_DIR AND STRATA_ORACLE_BUILD_DIR)
foreach(parity IN ITEMS native_mmvq native_bf16 native_shared native_gr_norm native_moe native_gdn native_gr_postops native_router native_gdn_preprocess native_gdn_pipeline native_qsa native_rope native_ple_postops native_flash_attn native_qsa_indexer native_qsa_score)
add_executable(${parity}_parity bench/micro/${parity}_parity.cpp)
target_include_directories(${parity}_parity PRIVATE
"${STRATA_ORACLE_SOURCE_DIR}/ggml/include" ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
"${STRATA_ORACLE_SOURCE_DIR}/ggml/include" ${_strata_gpu_include_directories})
target_compile_definitions(${parity}_parity PRIVATE GGML_SHARED GGML_BACKEND_SHARED)
target_link_libraries(${parity}_parity PRIVATE strata_kernels CUDA::cudart)
target_link_libraries(${parity}_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
foreach(component IN ITEMS ggml ggml-base ggml-cpu ggml-cuda)
string(REPLACE "-" "_" variable "STRATA_ORACLE_${component}")
find_library(${variable} NAMES ${component} PATHS
@@ -254,101 +310,199 @@ if(STRATA_ENABLE_CUDA)
# library is the honest shape of the dependency graph: core knows about devices and weights, kernels know
# about arithmetic, and this one knows the ORDER the arithmetic happens in.
add_library(strata_engine STATIC src/core/layer.cpp src/core/session.cpp src/core/expert_source.cpp src/core/remote_experts.cpp
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp)
src/core/expert_cache.cpp src/core/native_head.cpp src/core/native_dense.cpp src/core/verify.cpp src/core/mtp.cpp
src/core/conversation_snapshot.cpp src/core/conversation_state.cpp src/core/conversation_memory.cpp)
target_include_directories(strata_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(strata_engine PUBLIC strata_core strata_kernels strata_kernels_cpu CUDA::cudart)
target_link_libraries(strata_engine PUBLIC strata_core strata_kernels strata_kernels_cpu ${_strata_gpu_runtime_target})
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/core/file_expert_source_test.cpp")
enable_testing()
add_executable(file_expert_source_test tests/core/file_expert_source_test.cpp)
target_link_libraries(file_expert_source_test PRIVATE strata_engine)
add_test(NAME file_expert_source_test COMMAND file_expert_source_test)
set_tests_properties(file_expert_source_test PROPERTIES TIMEOUT 60)
endif()
if(STRATA_BUILD_CONVERSATION_TESTS)
add_executable(conversation_snapshot_test src/core/conversation_snapshot_test.cpp)
target_link_libraries(conversation_snapshot_test PRIVATE strata_engine)
add_test(NAME conversation_snapshot_test COMMAND conversation_snapshot_test)
set_tests_properties(conversation_snapshot_test PROPERTIES SKIP_RETURN_CODE 77 TIMEOUT 120)
add_executable(conversation_validation_test src/core/conversation_validation_test.cpp)
target_link_libraries(conversation_validation_test PRIVATE strata_engine)
add_test(NAME conversation_validation_test COMMAND conversation_validation_test)
set_tests_properties(conversation_validation_test PROPERTIES ENVIRONMENT "CUDA_VISIBLE_DEVICES=-1" TIMEOUT 120)
if(NOT STRATA_ENABLE_HIP AND UNIX AND NOT APPLE AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_executable(conversation_transfer_test src/core/conversation_validation_test.cpp)
target_compile_definitions(conversation_transfer_test PRIVATE CONVERSATION_TEST_TRANSFERS)
target_link_options(conversation_transfer_test PRIVATE
-Wl,--wrap=cudaMemcpy -Wl,--wrap=cudaDeviceSynchronize -Wl,--wrap=cudaGetLastError)
target_link_libraries(conversation_transfer_test PRIVATE strata_engine)
add_test(NAME conversation_transfer_test COMMAND conversation_transfer_test)
set_tests_properties(conversation_transfer_test PROPERTIES ENVIRONMENT "CUDA_VISIBLE_DEVICES=-1" TIMEOUT 120)
endif()
endif()
# `strata generate` (P2.S6). The FIRST user-facing program: everything under it is a component, and this is
# what composes them into a token. No tokenizer - the prompt arrives as ids - because Gate C1 compares logits
# against llama.cpp on the SAME ids and a tokenizer on one side of that comparison is a second variable.
# plan v0.3 P5: batched prompt processing (tensor-core GEMMs through cuBLAS).
add_library(strata_prefill STATIC src/prefill/gemm.cu src/prefill/kernels.cu src/prefill/prefill.cpp)
target_include_directories(strata_prefill PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(strata_prefill PUBLIC strata_engine CUDA::cublas CUDA::cudart)
target_include_directories(strata_prefill PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include ${_strata_gpu_include_directories})
target_link_libraries(strata_prefill PUBLIC strata_engine ${_strata_gpu_blas_target} ${_strata_gpu_runtime_target})
if(STRATA_ENABLE_HIP AND STRATA_HIPBLASLT_AVAILABLE)
target_compile_definitions(strata_prefill PRIVATE STRATA_HIPBLASLT_AVAILABLE=1)
target_link_libraries(strata_prefill PRIVATE roc::hipblaslt)
add_executable(tune_hipblaslt tools/hip/tune_hipblaslt.cpp)
target_link_libraries(tune_hipblaslt PRIVATE hip::host roc::hipblas roc::hipblaslt)
endif()
add_executable(strata src/program/generate.cpp)
target_include_directories(strata PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(strata PRIVATE strata_engine strata_prefill strata_spec CUDA::cudart)
target_include_directories(strata PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(strata PRIVATE strata_engine strata_prefill strata_spec ${_strata_gpu_runtime_target})
if(STRATA_ENABLE_HIP)
enable_testing()
set(_strata_hip_tests)
add_executable(hip_expert_cache_staging tests/hip/expert_cache_staging.cpp)
target_link_libraries(hip_expert_cache_staging PRIVATE strata_engine)
add_test(NAME hip_expert_cache_staging COMMAND hip_expert_cache_staging)
list(APPEND _strata_hip_tests hip_expert_cache_staging)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/hip/intrinsics.cpp")
add_executable(hip_intrinsics tests/hip/intrinsics.cpp)
target_link_libraries(hip_intrinsics PRIVATE strata_hip_runtime)
add_test(NAME hip_intrinsics COMMAND hip_intrinsics)
list(APPEND _strata_hip_tests hip_intrinsics)
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/hip/handoff.cpp")
add_executable(hip_handoff tests/hip/handoff.cpp)
target_link_libraries(hip_handoff PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_handoff COMMAND hip_handoff)
list(APPEND _strata_hip_tests hip_handoff)
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)
add_test(NAME hip_native_qsa_score COMMAND hip_native_qsa_score)
list(APPEND _strata_hip_tests hip_native_qsa_score)
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/hip/prefill_native_batch.cpp")
add_executable(hip_prefill_native_batch tests/hip/prefill_native_batch.cpp)
target_link_libraries(hip_prefill_native_batch PRIVATE strata_kernels strata_hip_runtime)
add_test(NAME hip_prefill_native_batch COMMAND hip_prefill_native_batch)
list(APPEND _strata_hip_tests hip_prefill_native_batch)
endif()
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")
target_link_libraries(hip_prefill_hipblaslt_gemm PRIVATE strata_prefill strata_hip_runtime roc::hipblaslt roc::hipblas)
add_test(NAME hip_prefill_hipblaslt_gemm COMMAND hip_prefill_hipblaslt_gemm)
set_tests_properties(hip_prefill_hipblaslt_gemm PROPERTIES TIMEOUT 120 SKIP_RETURN_CODE 77)
list(APPEND _strata_hip_tests hip_prefill_hipblaslt_gemm)
endif()
if(_strata_hip_tests)
set_tests_properties(${_strata_hip_tests} PROPERTIES TIMEOUT 60)
endif()
if(TARGET hip_prefill_native_batch)
set_tests_properties(hip_prefill_native_batch PROPERTIES TIMEOUT 120)
endif()
if(TARGET hip_prefill_hipblaslt_gemm)
set_tests_properties(hip_prefill_hipblaslt_gemm PROPERTIES TIMEOUT 120 SKIP_RETURN_CODE 77)
endif()
endif()
add_executable(dequant_s2_parity src/kernels/dequant_s2_parity.cpp)
# The tests are HOST C++ files that call cudaMalloc/cudaMemcpy themselves, so they need the toolkit headers
# and cudart explicitly: enabling the CUDA language gives those to .cu sources automatically, but a .cpp
# target is just a C++ target and gets neither.
target_include_directories(dequant_s2_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(dequant_s2_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(dequant_s2_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(dequant_s2_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME dequant_s2_parity COMMAND dequant_s2_parity --selftest)
add_executable(s2_gemv_parity src/kernels/s2_gemv_parity.cpp)
target_include_directories(s2_gemv_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(s2_gemv_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(s2_gemv_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(s2_gemv_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME s2_gemv_parity COMMAND s2_gemv_parity --selftest)
add_executable(iq_parity src/kernels/iq_parity.cpp)
target_include_directories(iq_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(iq_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(iq_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(iq_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
if(STRATA_ENABLE_CUDA)
# native_mmvq's multi_exact contract, bitwise: a T-column call against T single-column calls on the same Q8_1
# bytes, with the flag off as the negative control (synthetic, GPU, no model; the control assumes 32-lane warps)
add_executable(mmvq_multi_parity src/kernels/mmvq_multi_parity.cpp)
target_include_directories(mmvq_multi_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(mmvq_multi_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME mmvq_multi_parity COMMAND mmvq_multi_parity)
endif()
add_executable(shared_expert_parity src/kernels/shared_expert_parity.cpp)
target_include_directories(shared_expert_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(shared_expert_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(shared_expert_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(shared_expert_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME shared_expert_parity COMMAND shared_expert_parity --selftest)
add_executable(gr_parity src/kernels/gr_parity.cpp)
target_include_directories(gr_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(gr_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(gr_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(gr_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME gr_parity COMMAND gr_parity --selftest)
add_executable(gdn_parity src/kernels/gdn_parity.cpp)
target_include_directories(gdn_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(gdn_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(gdn_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(gdn_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME gdn_parity COMMAND gdn_parity --selftest)
add_executable(s2_gemv_q8_parity src/kernels/s2_gemv_q8_parity.cpp)
target_include_directories(s2_gemv_q8_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(s2_gemv_q8_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(s2_gemv_q8_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(s2_gemv_q8_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME s2_gemv_q8_parity COMMAND s2_gemv_q8_parity --selftest)
add_executable(sampler_parity src/kernels/sampler_parity.cpp)
target_include_directories(sampler_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(sampler_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(sampler_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(sampler_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME sampler_parity COMMAND sampler_parity --selftest)
set_tests_properties(sampler_parity PROPERTIES ENVIRONMENT "STRATA_OLD_SAMPLER=0;STRATA_SAMPLER_ONE_BLOCK=0")
# the same fixtures on the other two sampled paths: the one-block kernel and the 0.1.20 kernel
add_test(NAME sampler_parity_one_block COMMAND sampler_parity --selftest)
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")
add_executable(rope_parity src/kernels/rope_parity.cpp)
target_include_directories(rope_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(rope_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(rope_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(rope_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME rope_parity COMMAND rope_parity --selftest)
add_executable(quantize_act_parity src/kernels/quantize_act_parity.cpp)
target_include_directories(quantize_act_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(quantize_act_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(quantize_act_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(quantize_act_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME quantize_act_parity COMMAND quantize_act_parity --selftest)
add_executable(router_top10_parity src/kernels/router_top10_parity.cpp)
target_include_directories(router_top10_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(router_top10_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(router_top10_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(router_top10_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME router_top10_parity COMMAND router_top10_parity --selftest)
add_executable(s_gemv_parity src/kernels/s_gemv_parity.cpp)
target_include_directories(s_gemv_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(s_gemv_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(s_gemv_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(s_gemv_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME s_gemv_parity COMMAND s_gemv_parity --selftest)
add_executable(elementwise_parity src/kernels/elementwise_parity.cpp)
target_include_directories(elementwise_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(elementwise_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(elementwise_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(elementwise_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME elementwise_parity COMMAND elementwise_parity --selftest)
add_executable(bf16_gemv_parity src/kernels/bf16_gemv_parity.cpp)
target_include_directories(bf16_gemv_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(bf16_gemv_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(bf16_gemv_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(bf16_gemv_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME bf16_gemv_parity COMMAND bf16_gemv_parity --selftest)
add_executable(s_gemv_q8k_parity src/kernels/s_gemv_q8k_parity.cpp)
target_include_directories(s_gemv_q8k_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(s_gemv_q8k_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(s_gemv_q8k_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(s_gemv_q8k_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME s_gemv_q8k_parity COMMAND s_gemv_q8k_parity --selftest)
add_executable(qsa_parity src/kernels/qsa_parity.cpp)
target_include_directories(qsa_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(qsa_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(qsa_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(qsa_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME qsa_parity COMMAND qsa_parity --selftest)
# ---- P2.S4: the PLE n-gram hash, the IQ4_NL table read, and the PLE block ----
@@ -360,8 +514,8 @@ if(STRATA_ENABLE_CUDA)
# INSIDE the test. A test that passes by finding nothing to compare is the failure mode this project keeps
# re-learning.
add_executable(ple_parity src/kernels/ple_parity.cpp)
target_include_directories(ple_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(ple_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(ple_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(ple_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
set(STRATA_PLE_FIXTURE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro" CACHE PATH
"Directory containing required ple_in.bin and ple_out.bin parity fixtures")
add_test(NAME ple_parity COMMAND ple_parity --selftest
@@ -370,73 +524,92 @@ if(STRATA_ENABLE_CUDA)
# Plan v0.3 P7: INT8 KV storage vs a host reference and vs the FP16 path (GPU, synthetic, no model).
add_executable(kv_q8_parity src/kernels/kv_q8_parity.cpp)
target_include_directories(kv_q8_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(kv_q8_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(kv_q8_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(kv_q8_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME kv_q8_parity COMMAND kv_q8_parity)
# KV streaming (docs/kv-streaming-design.md): streamed vs resident attention, bitwise (GPU, synthetic, no model).
add_executable(kv_stream_parity src/kernels/kv_stream_parity.cpp)
target_include_directories(kv_stream_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(kv_stream_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(kv_stream_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(kv_stream_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME kv_stream_parity COMMAND kv_stream_parity)
# PR #120: the hybrid K8V4 KV (INT8 K + rotated Q4_0 V) vs host references (GPU, synthetic, no model).
add_executable(kv_hybrid_parity src/kernels/kv_hybrid_parity.cpp)
target_include_directories(kv_hybrid_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(kv_hybrid_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME kv_hybrid_parity COMMAND kv_hybrid_parity)
# PR #21: Q4_0 KV with Walsh-Hadamard rotation vs a host reference (GPU, synthetic, no model).
add_executable(kv_q4_parity src/kernels/kv_q4_parity.cpp)
target_include_directories(kv_q4_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(kv_q4_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(kv_q4_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(kv_q4_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME kv_q4_parity COMMAND kv_q4_parity)
add_executable(cvec_parity src/kernels/cvec_parity.cpp)
target_include_directories(cvec_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(cvec_parity PRIVATE strata_kernels CUDA::cudart)
target_include_directories(cvec_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(cvec_parity PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME cvec_parity COMMAND cvec_parity)
# Plan v0.3 P3: native MMVQ for 1..8 tokens (bitwise selftest + --bench timing); synthetic data, no model.
if(STRATA_BUILD_TESTS)
add_executable(native_mmvq_multi bench/micro/native_mmvq_multi.cpp)
target_include_directories(native_mmvq_multi PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(native_mmvq_multi PRIVATE strata_kernels CUDA::cudart)
add_test(NAME native_mmvq_multi COMMAND native_mmvq_multi --selftest)
if(STRATA_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro/native_mmvq_multi.cpp")
add_executable(native_mmvq_multi bench/micro/native_mmvq_multi.cpp)
target_include_directories(native_mmvq_multi PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_mmvq_multi PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME native_mmvq_multi COMMAND native_mmvq_multi --selftest)
endif()
# Plan v0.3 P0.1: working-set lock of a host region (CPU only).
add_executable(platform_memory_test src/platform/memory_test.cpp)
target_link_libraries(platform_memory_test PRIVATE strata_core CUDA::cudart)
target_link_libraries(platform_memory_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME platform_memory_test COMMAND platform_memory_test)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_executable(pinned_shared_test src/core/pinned_shared_test.cpp)
target_link_libraries(pinned_shared_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME pinned_shared_test COMMAND pinned_shared_test)
endif()
# Plan v0.3 P2: the n-gram table read straight from the SSD. The selftest needs only the CPU and a temp file.
add_executable(ple_reader_test src/ngram/ple_reader_test.cpp)
target_include_directories(ple_reader_test PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(ple_reader_test PRIVATE strata_kernels CUDA::cudart)
target_include_directories(ple_reader_test PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(ple_reader_test PRIVATE strata_kernels ${_strata_gpu_runtime_target})
add_test(NAME ple_reader_selftest COMMAND ple_reader_test --selftest)
# The GPU test is registered only when CUDA is on, and it runs ON THE TARGET PC. `--selftest` allocates
# The GPU test runs ON THE TARGET PC. `--selftest` allocates
# and writes real VRAM, so a machine without a device fails this test rather than skipping it silently -
# which is correct: an engine that targets one GPU should not report a pass for having no GPU.
add_test(NAME cuda_device_selftest COMMAND strata-device --selftest)
if(STRATA_ENABLE_CUDA)
add_test(NAME cuda_device_selftest COMMAND strata-device --selftest)
else()
add_test(NAME hip_device_selftest COMMAND strata-device --selftest)
endif()
# ---- P2.S5: the GraphRegistry --------------------------------------------------
# A .cu because the test needs real kernels to capture; it links `strata_core` and not `strata_kernels`,
# because what it exercises is the capture machinery and not any kernel in it.
if(STRATA_BUILD_TESTS)
add_executable(graph_registry_test bench/micro/graph_registry_test.cu)
target_include_directories(graph_registry_test PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(graph_registry_test PRIVATE strata_core CUDA::cudart)
add_test(NAME graph_registry_test COMMAND graph_registry_test)
if(STRATA_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro/graph_registry_test.cu")
add_executable(graph_registry_test bench/micro/graph_registry_test.cu)
target_include_directories(graph_registry_test PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(graph_registry_test PRIVATE strata_core ${_strata_gpu_runtime_target})
add_test(NAME graph_registry_test COMMAND graph_registry_test)
endif()
# ---- P2.S5's GATE: is a device<->pinned-memory handoff graph-capturable? --------------------
# The doorbell protocol puts the D2H of `x_f` and the miss list INSIDE G[l] - that is the whole overlap. The
# phase doc says to verify this "before building on it", and this project has already lost two rounds to
# building first (a memory-only spin that DEADLOCKS on Windows, and a `cudaEventRecord` inside a capture that
# is SILENTLY DROPPED). It links only cudart: it is about the capture machinery, not about any kernel.
add_executable(pinned_capture bench/micro/pinned_capture.cu)
target_include_directories(pinned_capture PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(pinned_capture PRIVATE CUDA::cudart)
add_test(NAME pinned_capture COMMAND pinned_capture)
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro/pinned_capture.cu")
add_executable(pinned_capture bench/micro/pinned_capture.cu)
target_include_directories(pinned_capture PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(pinned_capture PRIVATE ${_strata_gpu_runtime_target})
add_test(NAME pinned_capture COMMAND pinned_capture)
endif()
# Tells tests/CMakeLists.txt to add the CUDA-dependent Catch2 target. It cannot be declared there
# unconditionally: tests/ is added at the end of this file, but the flag has to be known before it runs.
set(STRATA_CUDA_TESTS ON)
if(STRATA_ENABLE_CUDA)
set(STRATA_CUDA_TESTS ON)
endif()
endif()
# ---- strata_kernels_cpu (P2.S3) ----
@@ -463,10 +636,20 @@ if(STRATA_BUILD_TESTS)
add_executable(draft_policy_test src/spec/draft_policy_test.cpp)
target_link_libraries(draft_policy_test PRIVATE strata_spec)
add_test(NAME draft_policy_test COMMAND draft_policy_test)
endif()
if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
# the serve loop's conversation-cache retention policy (header-only, no library to link)
add_executable(conv_cache_test src/program/conv_cache_test.cpp)
target_include_directories(conv_cache_test PRIVATE include)
add_test(NAME conv_cache_test COMMAND conv_cache_test)
# coupled draft sampling's counter and penalty-ring arithmetic (header-only, CPU)
add_executable(coupled_draft_test src/core/coupled_draft_test.cpp)
target_include_directories(coupled_draft_test PRIVATE include)
add_test(NAME coupled_draft_test COMMAND coupled_draft_test)
# the f32 -> bf16 rounding against ggml's, NaN included, over every f32 bit pattern (header-only, CPU)
add_executable(bf16_bits_test src/kernels/bf16_bits_test.cpp)
target_include_directories(bf16_bits_test PRIVATE include)
add_test(NAME bf16_bits_test COMMAND bf16_bits_test)
endif()
add_library(strata_kernels_cpu STATIC src/kernels/cpu/expert.cpp src/kernels/cpu/pool.cpp
@@ -493,6 +676,9 @@ target_link_libraries(strata_kernels_cpu PUBLIC Threads::Threads)
# with -DSTRATA_GGML_DIR=..., otherwise the pinned upstream commit (third_party/ggml/VERSION.txt).
option(STRATA_NATIVE_EXPERTS "Native (IQ) expert support through ggml-cpu" ON)
set(STRATA_GGML_DIR "" CACHE PATH "llama.cpp checkout to take ggml from (empty: fetch the pinned commit)")
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_NATIVE_EXPERTS)
enable_language(C)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
@@ -550,7 +736,7 @@ if(STRATA_NATIVE_EXPERTS)
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)
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)
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}
@@ -559,28 +745,71 @@ if(STRATA_NATIVE_EXPERTS)
if(MSVC)
target_compile_options(strata_mmq PRIVATE $<$<COMPILE_LANGUAGE:CUDA>:-Xcompiler=/bigobj>)
endif()
target_link_libraries(strata_mmq PUBLIC ggml-base CUDA::cudart)
target_link_libraries(strata_mmq PUBLIC ggml-base ${_strata_gpu_runtime_target})
target_link_libraries(strata_prefill PUBLIC strata_mmq)
target_compile_definitions(strata_prefill PUBLIC STRATA_PREFILL_MMQ=1)
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.
if(STRATA_GGML_DIR)
set(_strata_gg "${STRATA_GGML_DIR}/ggml")
else()
set(_strata_gg "${strata_llamacpp_SOURCE_DIR}/ggml")
endif()
set(_strata_mmq "${_strata_gg}/src/ggml-cuda")
if(NOT EXISTS "${_strata_mmq}/common.cuh" OR NOT EXISTS "${_strata_mmq}/quantize.cu")
message(FATAL_ERROR "The selected GGML source is missing the ggml-cuda MMQ sources: ${_strata_mmq}")
endif()
set(_strata_mmq_sources
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)
list(APPEND _strata_mmq_sources "${_strata_mmq}/template-instances/mmq-instance-${_t}.cu")
endforeach()
set_source_files_properties(${_strata_mmq_sources} PROPERTIES LANGUAGE HIP)
add_library(strata_mmq STATIC ${_strata_mmq_sources})
target_include_directories(strata_mmq
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)
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)
# Numerical diagnostic: synthetic layouts by default, optional first-expert IQ3_XXS/IQ4_NL check with a pack.
add_executable(hip_prefill_mmq_parity tests/hip/prefill_mmq_parity.cpp)
target_include_directories(hip_prefill_mmq_parity PRIVATE "${_strata_gg}/include")
target_link_libraries(hip_prefill_mmq_parity PRIVATE strata_prefill)
add_test(NAME hip_prefill_mmq_parity COMMAND hip_prefill_mmq_parity)
set_tests_properties(hip_prefill_mmq_parity PROPERTIES TIMEOUT 120)
list(APPEND _strata_hip_tests hip_prefill_mmq_parity)
endif()
if(STRATA_ENABLE_CUDA)
if(STRATA_ENABLE_HIP)
# Optional real-model diagnostic: pass the GGUF shard containing the native PLE key.
add_executable(hip_ple_iq4 tests/hip/ple_iq4.cpp)
target_link_libraries(hip_ple_iq4 PRIVATE strata_kernels strata_hip_runtime ggml-base)
endif()
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
add_executable(native_expert_parity src/kernels/native_expert_parity.cpp)
target_include_directories(native_expert_parity PRIVATE ${CMAKE_CUDA_TOOLKIT_INCLUDE_DIRECTORIES})
target_link_libraries(native_expert_parity PRIVATE strata_kernels strata_kernels_cpu strata_artifact CUDA::cudart)
target_include_directories(native_expert_parity PRIVATE ${_strata_gpu_include_directories})
target_link_libraries(native_expert_parity PRIVATE strata_kernels strata_kernels_cpu strata_artifact ${_strata_gpu_runtime_target})
endif()
endif()
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS)
if(STRATA_ENABLE_CUDA AND STRATA_BUILD_TESTS AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/bench/micro/hit_cpu_order_parity.cu")
add_executable(hit_cpu_order_parity bench/micro/hit_cpu_order_parity.cu)
target_link_libraries(hit_cpu_order_parity PRIVATE strata_kernels strata_kernels_cpu CUDA::cudart)
target_link_libraries(hit_cpu_order_parity PRIVATE strata_kernels strata_kernels_cpu ${_strata_gpu_runtime_target})
endif()
if(STRATA_BUILD_TESTS)
# Plan v0.3 P6: one expert applied to several tokens, bitwise vs the single-token kernel (synthetic, CPU only).
# plan v0.3 P5: the device dequantizers vs the artifact's CPU dequantizers on the real shards (not a ctest: needs
# the 38 GB model; run it by hand or from tools/gpu_session.py)
add_executable(dequant_bf16_test src/kernels/dequant_bf16_test.cpp)
target_link_libraries(dequant_bf16_test PRIVATE strata_kernels strata_artifact CUDA::cudart)
if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP) # #93: it needs the device dequantizers and a GPU runtime
add_executable(dequant_bf16_test src/kernels/dequant_bf16_test.cpp)
target_link_libraries(dequant_bf16_test PRIVATE strata_kernels strata_artifact ${_strata_gpu_runtime_target})
endif()
add_executable(expert_multi_test src/kernels/cpu/expert_multi_test.cpp)
target_link_libraries(expert_multi_test PRIVATE strata_kernels_cpu)
@@ -600,12 +829,19 @@ if(STRATA_BUILD_TESTS)
add_executable(pool_stress src/kernels/cpu/pool_stress.cpp)
target_link_libraries(pool_stress PRIVATE strata_kernels_cpu)
add_test(NAME pool_stress COMMAND pool_stress 20)
if(UNIX AND NOT APPLE)
add_executable(direct_file_async_test tests/platform/direct_file_async.cpp src/platform/direct_file.cpp)
target_include_directories(direct_file_async_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(direct_file_async_test PRIVATE Threads::Threads)
add_test(NAME direct_file_async_test COMMAND direct_file_async_test)
endif()
endif()
if(STRATA_BUILD_TESTS)
enable_testing()
# the unit tests above build from src/; the tests/ tree (model-level checks) is not part of the published repo
if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt)
# Model-level checks may be absent from source distributions.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt")
add_subdirectory(tests)
endif()
endif()
+116
View File
@@ -0,0 +1,116 @@
# syntax=docker/dockerfile:1
#
# Strata: Qwen3.8-Flash-Next on NVIDIA GPUs (RTX 30/40/50, 12+ GB VRAM; two or
# three cards can share one model, 8 GB each - docs/MULTI_GPU.md).
#
# The engine is compiled during docker build, so the first container start only
# downloads the model (~70 GB) and starts the server. docker build has no GPU,
# so the CUDA architectures are fixed here instead of read from nvidia-smi: the
# engine is a fat binary with a cubin per listed arch, and the runtime picks the
# one matching your card. Narrow CUDA_ARCHITECTURES to your card for a faster
# build; a card outside the set needs a rebuild with its own arch.
#
# Build:
# docker build -t strata .
# docker build -t strata --build-arg CUDA_ARCHITECTURES=89 . # RTX 40 only
#
# Run (host needs an NVIDIA driver >= 580 and nvidia-container-toolkit):
# docker run --rm --gpus all \
# -p 8080:8080 \
# --ulimit memlock=-1 \
# -v strata-data:/data \
# -e MODEL=IQ2_XS \
# strata
#
# Setup choices are env vars, read by docker-entrypoint.sh: FAMILY, MODEL, CONTEXT,
# VISION (no | yes | cpu), KV (int8 | q4_0 | k8v4), GPU (one card) or GPUS ("0,2"
# or "all", with LAYER_SPLIT), LOW_RAM (auto | on | off), HOST, PORT, API_KEY.
#
# Only the model files, the prepared pack, the MTP layer and the install config
# live in the /data volume; the engine is part of the image. Strata loads 32-62 GB
# into RAM, so a capped container needs -e LOW_RAM=on: setup.py reads the RAM from
# /proc/meminfo, which here is the host's total, not the container's limit. Add an
# API key before exposing the port to a network: -e API_KEY=<secret>. Pass
# -e REINSTALL=1 to change the model settings later.
#
# --gpus all on a host with two usable cards: setup takes both (the layer split is
# its recommended default). Pin one card with -e GPU=0, or name them with
# -e GPUS=0,2. A volume set up for one card switches to the pair on its first start
# on a two-card host unless GPU or GPUS pins it. LOW_RAM=on runs on one card.
FROM nvidia/cuda:13.0.0-devel-ubuntu24.04
# STRATA_EXECV=1: setup.py replaces itself with the server, so the server is PID 1
# and docker stop's SIGTERM reaches it (see setup.start). Normal Linux starts, which
# don't set it, keep spawning the server as a child.
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1 LANG=C.UTF-8 STRATA_EXECV=1
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential ca-certificates curl git libatomic1 libgomp1 \
python3 python3-pip python3-venv unzip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/strata
COPY . .
# RTX 20 (75), RTX 30 (86), RTX 40 (89), RTX 50 (120), plus 80 for A-series. CMakeLists
# refuses anything below 75. BUILD_VISION=0 skips the image encoder build.
ARG CUDA_ARCHITECTURES=75;80;86;89;120
ARG BUILD_VISION=1
RUN python3 -m venv .venv \
&& .venv/bin/pip install --no-cache-dir --upgrade pip \
&& .venv/bin/pip install --no-cache-dir \
numpy jinja2 regex pyyaml tqdm requests cmake ninja pillow psutil \
&& chmod +x setup.sh docker-entrypoint.sh
# llama.cpp at the pinned commit, then the engine and the image encoder, built
# exactly the way setup.py builds them. BUILD.json is what setup.py reads to
# decide whether an engine is current: source=local with a matching src hash
# means the first start reuses it instead of recompiling.
RUN .venv/bin/python - <<'PYEOF'
import json, os, pathlib, shutil
import setup
llama = setup.get_llama_cpp()
nvcc, _ = setup.find_nvcc()
arch = os.environ.get("CUDA_ARCHITECTURES", "75;80;86;89;120").strip().strip('"').replace(",", ";")
vision = "gpu" if os.environ.get("BUILD_VISION", "1") == "1" else "none"
setup.cmake_build(setup.ROOT, setup.ROOT / "build", "strata",
["-DSTRATA_ENABLE_CUDA=ON", "-DSTRATA_BUILD_TESTS=OFF",
f"-DCMAKE_CUDA_ARCHITECTURES={arch}", f"-DCMAKE_CUDA_COMPILER={nvcc}",
f"-DSTRATA_GGML_DIR={llama}"], None, "build-strata.bat")
if vision != "none":
setup.cmake_build(setup.ROOT / "tools" / "vision", setup.ROOT / "build-vision", "strata-vision",
[f"-DLLAMA_DIR={llama}", "-DSTRATA_VISION_CUDA=ON",
f"-DCMAKE_CUDA_ARCHITECTURES={arch}", f"-DCMAKE_CUDA_COMPILER={nvcc}"], None, "build-vision.bat")
eng = setup.ROOT / "engine"
eng.mkdir(exist_ok=True)
shutil.copy2(setup.ROOT / "build" / setup.EXE, eng / setup.EXE)
if vision != "none":
shutil.copy2(setup.ROOT / "build-vision" / "bin" / setup.VEXE, eng / setup.VEXE)
bindir = pathlib.Path(nvcc).parent
meta = {"source": "local", "version": setup.source_version(),
"archs": [int(a.split("-")[0]) for a in arch.split(";") if a.split("-")[0].isdigit()], "vision": vision,
"cuda_dirs": [str(d) for d in (bindir, bindir / "x64", bindir.parent / "lib64") if d.is_dir()],
"src": setup.source_hash(setup.ENGINE_SOURCES),
"vision_src": setup.source_hash(setup.VISION_SOURCES) if vision != "none" else None}
(eng / "BUILD.json").write_text(json.dumps(meta, indent=1))
PYEOF
# the cmake trees are build-time only; the engine itself is what the container needs
RUN rm -rf build build-vision
VOLUME ["/data"]
EXPOSE 8080
# /health is answered before the API key gate, so it works with or without one.
# The port only opens after the model loads (1-3 minutes, longer on a first run),
# so the start period is generous: a too short one marks a still-loading container
# unhealthy and a restart policy would kill it mid-download.
HEALTHCHECK --interval=30s --timeout=5s --start-period=600s --retries=3 \
CMD curl -fs "http://127.0.0.1:${PORT:-8080}/health" || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
+48 -9
View File
@@ -25,15 +25,15 @@ 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** | 90 tokens/s | 67 tokens/s | 1,310 tokens/s |
| **IQ2_XS** | 74 tokens/s | 60 tokens/s | 1,240 tokens/s |
| **IQ3_XXS** | 62 tokens/s | 46 tokens/s | 1,110 tokens/s |
| **IQ3_S** | 52 tokens/s | 41 tokens/s | 1,070 tokens/s |
| **Coder** (IQ1_M) | 51 tokens/s | 44 tokens/s | 1,300 tokens/s |
| **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 |
- **Writes answers** = how fast the reply appears (tokens per second).
- **Reads your prompt** = how fast it takes in what you send (long documents, code, chat history), measured on a
32K-token prompt; a 4K prompt reads at 740-1,000 tokens/s. A 32K prompt takes about 25 seconds with Q2_0.
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
@@ -47,7 +47,7 @@ asks whether to share the model across them (recommended when two can). An insta
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 30 series or newer with 8 GB or more. See [docs/MULTI_GPU.md](docs/MULTI_GPU.md).
Every card must be an RTX 20 series or newer with 8 GB or more. See [docs/MULTI_GPU.md](docs/MULTI_GPU.md).
## Which model should I pick?
@@ -84,9 +84,14 @@ one later with `SETUP.bat` (the same as `START-HERE.bat --setup`; on Linux `./se
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):
`./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 (one GPU, no images yet). Details: [AMD HIP](docs/AMD_HIP.md).
## Install
**You need:** an NVIDIA RTX 30, 40 or 50 card with 12 GB of VRAM or more, enough RAM for the size you pick (above),
**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.
@@ -97,7 +102,9 @@ App). Everything else - Python, the engine, the model - is set up for you.
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)
- **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
@@ -117,6 +124,38 @@ the same way - nothing big is downloaded again.
**Linux:** run `./setup.sh` - same questions, same result.
**Docker (Linux):** the same idea, in a container.
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 ([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.
## 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>
@@ -0,0 +1,49 @@
# Rope scaling: needle recall past the trained 262K
The feature (PR #84, experimental): `--rope-scaling none|linear|yarn` extends the context past the model's
trained 262,144 positions by rescaling the rotary angles - llama.cpp's types, ggml's `rope_yarn`
arithmetic, one process config applied at the cos/sin table (default path) and as kernel constants
(`--native-rope`, the native indexer, prefill). The three tables here are the first end-to-end check
that a scaled run actually recalls past the trained end.
**Hardware:** RTX 4070 Ti SUPER 16 GB, Ryzen 9 5900X (no AVX-512 - the IQ3_XXS native pack runs its
expert kernels on AVX-2), 125 GB RAM, Linux. **Engine:** this branch built from source (the log records
`rope scaling ... --max-context ... against a trained context of 262144` at startup). The runs below
were taken on the rope tree built over the 0.1.17-era base; the branch then rebased onto main (engine
0.1.20) - the drift touches none of the rope files - and the rebase was re-validated: `rope_parity`
green, a 64-token greedy run token-identical to the plain 0.1.20 build, and the yarn 262k probe
repeated green (see the last table row). Baseline arm A also re-checked the default is the stock
model: a 64-token greedy run produces the same token ids as the ready-made 0.1.17 engine, and
`rope_parity` holds the `none` table bit-identical to the pre-scaling builder.
**Method:** `tools/needle_bench.py` against a running server (launch recipes in `config-*.json`),
`temperature 0`, thinking off, depth 50%, one probe per length.
| Arm | Scaling | Context | Probe | Prompt tokens | Result | Probe time |
| --- | --- | --- | --- | --- | --- | --- |
| A | none | 262,144 | 1k @ 50% | 1,442 | FOUND | 4 s |
| A | none | 262,144 | 32k @ 50% | 31,763 | FOUND | 27 s |
| A | none | 262,144 | 128k @ 50% | 128,328 | FOUND | 86 s |
| B | linear, factor 2 | 560,000 | 262k @ 50% | 274,222 | FOUND | 232 s |
| B | linear, factor 2 | 560,000 | 512k @ 50% | 527,051 | FOUND | 619 s |
| C | yarn, factor 2 | 560,000 | 262k @ 50% | 274,215 | FOUND | 236 s |
| C | yarn, factor 2 | 560,000 | 512k @ 50% | 526,988 | FOUND | 615 s |
| C | yarn, factor 2 | 560,000 | 262k @ 50% (the rebase re-check) | 273,708 | FOUND | 235 s |
All 8 of 8 probes found the needle; JSON per arm: `needle-A-none.json`, `needle-B-linear2.json`,
`needle-B-linear2-512k.json`, `needle-C-yarn2.json` (+ the rebase re-check in
`needle-C-yarn2-rebased.json`); server and engine logs alongside.
What this establishes, and what it does not:
- **Past the trained end the model still reads.** 512k probes are 2.01x the trained 262,144 - position
interpolation (B) and YaRN (C) both hold a mid-context lookup there.
- **Scaled runs within the trained range are a slightly different model.** `yarn`'s mscale applies at
every position (a 64-token greedy prompt diverges from the stock run after ~19 tokens); arm A is the
stock model, arms B/C are not, by design.
- **One depth, one needle, one machine.** A single probe per (length, arm) at depth 50% says "recall
works there", not "no degradation anywhere" - a full depth sweep and a longer-length ladder (768K,
1M) on the bench machine (64 GB RAM there vs 125 GB here, so 262K IQ3_XXS fits either way) is the
natural follow-up. 512K prompts prefill at ~850-950 tokens/s on this card; the probe time is almost
entirely prefill.
- **The `560,000` context** in arms B/C is headroom for the needle harness's tokenizer overshoot
(a "512k" probe tokenizes to ~527k); 524,288 exactly would refuse the probe.
@@ -0,0 +1,38 @@
{
"exe": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/build/strata",
"cwd": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata",
"tokenizer": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/packs/iq3_xxs/tokenizer",
"model_name": "qwen3.8-flash-next-iq3_xxs-rope-scaling",
"lib_dirs": [
"/usr/local/cuda/bin",
"/usr/local/cuda/lib64"
],
"port": 8130,
"args": [
"--pack",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/packs/iq3_xxs",
"--native",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/models/IQ3_XXS/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00001-of-00002.gguf",
"--ple-gguf",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/models/IQ3_XXS/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00002-of-00002.gguf",
"--expert-profile",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/data/expert-profile.bin",
"--expert-cache",
"auto",
"--prefill",
"auto",
"--spec",
"4",
"--spec-min-p",
"0.5",
"--mtp",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/mtp/rt",
"--kv",
"int8",
"--kv-resident",
"32768",
"--max-context",
"262144"
],
"log": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/bench/results/2026-09-28-rope-scaling/engine-A-none-262144.log"
}
@@ -0,0 +1,42 @@
{
"exe": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/build/strata",
"cwd": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata",
"tokenizer": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/packs/iq3_xxs/tokenizer",
"model_name": "qwen3.8-flash-next-iq3_xxs-rope-scaling",
"lib_dirs": [
"/usr/local/cuda/bin",
"/usr/local/cuda/lib64"
],
"port": 8130,
"args": [
"--pack",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/packs/iq3_xxs",
"--native",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/models/IQ3_XXS/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00001-of-00002.gguf",
"--ple-gguf",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/models/IQ3_XXS/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00002-of-00002.gguf",
"--expert-profile",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/data/expert-profile.bin",
"--expert-cache",
"auto",
"--prefill",
"auto",
"--spec",
"4",
"--spec-min-p",
"0.5",
"--mtp",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/mtp/rt",
"--kv",
"int8",
"--kv-resident",
"32768",
"--max-context",
"560000",
"--rope-scaling",
"linear",
"--rope-scale",
"2"
],
"log": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/bench/results/2026-09-28-rope-scaling/engine-B-linear2-524288.log"
}
@@ -0,0 +1,42 @@
{
"exe": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/build/strata",
"cwd": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata",
"tokenizer": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/packs/iq3_xxs/tokenizer",
"model_name": "qwen3.8-flash-next-iq3_xxs-rope-scaling",
"lib_dirs": [
"/usr/local/cuda/bin",
"/usr/local/cuda/lib64"
],
"port": 8130,
"args": [
"--pack",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/packs/iq3_xxs",
"--native",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/models/IQ3_XXS/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00001-of-00002.gguf",
"--ple-gguf",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/models/IQ3_XXS/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00002-of-00002.gguf",
"--expert-profile",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/data/expert-profile.bin",
"--expert-cache",
"auto",
"--prefill",
"auto",
"--spec",
"4",
"--spec-min-p",
"0.5",
"--mtp",
"/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata-data/mtp/rt",
"--kv",
"int8",
"--kv-resident",
"32768",
"--max-context",
"560000",
"--rope-scaling",
"yarn",
"--rope-scale",
"2"
],
"log": "/home/jakub/workspace/deepseek-harness/harnes_workspace/Strata/bench/results/2026-09-28-rope-scaling/engine-C-yarn2-524288.log"
}
@@ -0,0 +1,121 @@
[
{
"length": "128k",
"round": 0,
"kind": "decode",
"prompt_tok": 128401,
"read": 52,
"gen": 357,
"read_tok_s": 79.2,
"gen_tok_s": 56.3,
"drafts_ok": 182,
"drafts_all": 305,
"seg_blocks": 3284340,
"seg_mib": 370.0,
"seg_misses": 91744,
"seg_hit_pct": 97.207,
"kib_per_token": 1061.29
},
{
"length": "128k",
"round": 1,
"kind": "read",
"prompt_tok": 128357,
"read": 128357,
"gen": 24,
"read_tok_s": 1389.2,
"gen_tok_s": 41.7,
"drafts_ok": 11,
"drafts_all": 23,
"seg_blocks": -3253560,
"seg_mib": -391.7,
"seg_misses": -97147,
"seg_hit_pct": 97.014,
"kib_per_token": -3.12
},
{
"length": "128k",
"round": 1,
"kind": "decode",
"prompt_tok": 128402,
"read": 52,
"gen": 388,
"read_tok_s": 76.5,
"gen_tok_s": 54.6,
"drafts_ok": 195,
"drafts_all": 363,
"seg_blocks": 3752616,
"seg_mib": 463.5,
"seg_misses": 114911,
"seg_hit_pct": 96.938,
"kib_per_token": 1223.26
},
{
"length": "512k",
"round": 0,
"kind": "read",
"prompt_tok": 522983,
"read": 522983,
"gen": 24,
"read_tok_s": 868.4,
"gen_tok_s": 42.4,
"drafts_ok": 13,
"drafts_all": 16,
"seg_blocks": -3808056,
"seg_mib": -387.8,
"seg_misses": -96113,
"seg_hit_pct": 97.476,
"kib_per_token": -0.76
},
{
"length": "512k",
"round": 0,
"kind": "decode",
"prompt_tok": 523028,
"read": 52,
"gen": 402,
"read_tok_s": 63.3,
"gen_tok_s": 48.4,
"drafts_ok": 221,
"drafts_all": 348,
"seg_blocks": 3580056,
"seg_mib": 920.4,
"seg_misses": 228658,
"seg_hit_pct": 93.613,
"kib_per_token": 2344.5
},
{
"length": "512k",
"round": 1,
"kind": "read",
"prompt_tok": 522984,
"read": 522984,
"gen": 24,
"read_tok_s": 857.1,
"gen_tok_s": 35.5,
"drafts_ok": 9,
"drafts_all": 18,
"seg_blocks": -3555408,
"seg_mib": -905.8,
"seg_misses": -225016,
"seg_hit_pct": 93.671,
"kib_per_token": -1.77
},
{
"length": "512k",
"round": 1,
"kind": "decode",
"prompt_tok": 523029,
"read": 52,
"gen": 402,
"read_tok_s": 61.3,
"gen_tok_s": 42.8,
"drafts_ok": 192,
"drafts_all": 388,
"seg_blocks": 4005216,
"seg_mib": 1079.9,
"seg_misses": 268221,
"seg_hit_pct": 93.303,
"kib_per_token": 2750.79
}
]
@@ -0,0 +1,29 @@
[
{
"length": "1k",
"depth": 50,
"prompt_tokens": 1442,
"seconds": 3.7,
"found": true,
"word": "meadow-copper-504",
"answer": "meadow-copper-504"
},
{
"length": "32k",
"depth": 50,
"prompt_tokens": 31763,
"seconds": 27.5,
"found": true,
"word": "falcon-quartz-940",
"answer": "falcon-quartz-940"
},
{
"length": "128k",
"depth": 50,
"prompt_tokens": 128328,
"seconds": 86.1,
"found": true,
"word": "willow-cobalt-696",
"answer": "willow-cobalt-696"
}
]
@@ -0,0 +1,11 @@
[
{
"length": "512k",
"depth": 50,
"prompt_tokens": 527051,
"seconds": 619.0,
"found": true,
"word": "meadow-copper-504",
"answer": "meadow-copper-504"
}
]
@@ -0,0 +1,16 @@
[
{
"length": "262k",
"depth": 50,
"prompt_tokens": 274222,
"seconds": 232.2,
"found": true,
"word": "meadow-copper-504",
"answer": "meadow-copper-504"
},
{
"length": "512k",
"depth": 50,
"error": "HTTP Error 400: Bad Request"
}
]
@@ -0,0 +1,11 @@
[
{
"length": "262k",
"depth": 50,
"prompt_tokens": 273708,
"seconds": 234.8,
"found": true,
"word": "meadow-copper-504",
"answer": "meadow-copper-504"
}
]
@@ -0,0 +1,20 @@
[
{
"length": "262k",
"depth": 50,
"prompt_tokens": 274215,
"seconds": 235.5,
"found": true,
"word": "meadow-copper-504",
"answer": "meadow-copper-504"
},
{
"length": "512k",
"depth": 50,
"prompt_tokens": 526988,
"seconds": 615.0,
"found": true,
"word": "falcon-quartz-940",
"answer": "falcon-quartz-940"
}
]
@@ -0,0 +1,32 @@
# Speed with engine 0.1.22
RTX 5070 12 GB (PCIe 5.0 x16), Ryzen 5 7600, 64 GB DDR5-5200, Windows 10. The ready-made 0.1.22 engine, one-shot runs
with what setup writes: `--prefill auto`, the shipped expert profile, `--expert-cache auto`, 8-bit KV above 4K, KV
streaming (`--kv-resident 32768`) from 64K, MTP (`--spec 4 --spec-min-p 0.5`), greedy, 256 generated tokens, images
off. The same code-agent prompts as `2026-09-28-speed-0114`. Every run: [`matrix.json`](matrix.json).
## Prompt (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 519 | 1,226 | 1,844 | 1,836 | 1,682 | 1,304 |
| **IQ2_XS** | 524 | 1,196 | 1,799 | 1,611 | 1,495 | 1,181 |
| **IQ3_XXS** | 472 | 974 | 1,555 | 1,449 | 1,386 | - |
| **IQ3_S** | 419 | 893 | 1,499 | 1,285 | 1,245 | - |
| **Coder** | 660 | 1,522 | 1,871 | 1,938 | 1,779 | - |
## Output (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 84.7 | 88.2 | 77.4 | 71.0 | 63.1 | 61.1 |
| **IQ2_XS** | 77.2 | 74.6 | 67.5 | 63.5 | 59.1 | 54.7 |
| **IQ3_XXS** | 65.1 | 62.3 | 49.8 | 53.6 | 47.0 | - |
| **IQ3_S** | 50.5 | 44.6 | 49.0 | 48.8 | 41.3 | - |
| **Coder** | 56.9 | 53.6 | 54.9 | 48.5 | 42.8 | - |
- IQ2_XS was measured with Swift 1.5's IQ2_XS (the same speed as the original's); its 262K run had images on.
- IQ3_XXS and IQ3_S at 262K do not fit 64 GB of RAM; the Coder at 262K was not run again (0.1.14: 1,034 tokens/s).
- Against 0.1.14: prompts 1.2-1.5x faster at 32K-128K (the 0.1.22 prompt path: tensor-core attention, bit-identical
batched kernels, faster PLE reads). Output speed is the engine's same decode; one run per cell moves a few percent
with the text (speculative drafts), so compare it across many runs, not one.
@@ -0,0 +1,380 @@
[
{
"model": "Q2_0",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 40.0,
"decode_tok_s": 84.71,
"decoded": 256,
"prefill_tok_s": 519.48,
"ttft_s": 3.62,
"spec_accept": 0.786,
"tokens_per_round": 2.77,
"vram_slots": 4176
},
{
"model": "Q2_0",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 41.6,
"decode_tok_s": 88.23,
"decoded": 256,
"prefill_tok_s": 1226.49,
"ttft_s": 4.43,
"spec_accept": 0.839,
"tokens_per_round": 3.24,
"vram_slots": 4123
},
{
"model": "Q2_0",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 56.6,
"decode_tok_s": 77.37,
"decoded": 256,
"prefill_tok_s": 1843.83,
"ttft_s": 18.63,
"spec_accept": 0.808,
"tokens_per_round": 3.04,
"vram_slots": 3833
},
{
"model": "Q2_0",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 72.9,
"decode_tok_s": 71.0,
"decoded": 256,
"prefill_tok_s": 1835.64,
"ttft_s": 36.48,
"spec_accept": 0.685,
"tokens_per_round": 2.77,
"vram_slots": 3780
},
{
"model": "Q2_0",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 118.1,
"decode_tok_s": 63.08,
"decoded": 256,
"prefill_tok_s": 1681.64,
"ttft_s": 77.92,
"spec_accept": 0.7,
"tokens_per_round": 2.56,
"vram_slots": 3697
},
{
"model": "Q2_0",
"tier": "262k",
"prompt_tokens": 259943,
"exit": 0,
"wall_s": 242.0,
"decode_tok_s": 61.12,
"decoded": 256,
"prefill_tok_s": 1303.83,
"ttft_s": 200.94,
"spec_accept": 0.627,
"tokens_per_round": 2.5,
"vram_slots": 3525
},
{
"model": "IQ2_XS",
"tier": "262k",
"prompt_tokens": 259943,
"exit": 0,
"wall_s": 263.6,
"decode_tok_s": 54.68,
"decoded": 256,
"prefill_tok_s": 1180.58,
"ttft_s": 221.91,
"spec_accept": 0.632,
"tokens_per_round": 2.49,
"vram_slots": 3727
},
{
"model": "IQ3_S",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 56.0,
"decode_tok_s": 50.5,
"decoded": 256,
"prefill_tok_s": 419.48,
"ttft_s": 3.98,
"spec_accept": 0.811,
"tokens_per_round": 3.17,
"vram_slots": 2617
},
{
"model": "IQ3_S",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 60.0,
"decode_tok_s": 44.58,
"decoded": 256,
"prefill_tok_s": 892.84,
"ttft_s": 5.53,
"spec_accept": 0.672,
"tokens_per_round": 2.69,
"vram_slots": 2580
},
{
"model": "IQ3_S",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 75.6,
"decode_tok_s": 48.96,
"decoded": 256,
"prefill_tok_s": 1499.09,
"ttft_s": 22.65,
"spec_accept": 0.79,
"tokens_per_round": 2.9,
"vram_slots": 2383
},
{
"model": "IQ3_S",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 102.1,
"decode_tok_s": 48.8,
"decoded": 256,
"prefill_tok_s": 1284.81,
"ttft_s": 51.58,
"spec_accept": 0.797,
"tokens_per_round": 2.94,
"vram_slots": 2281
},
{
"model": "IQ3_S",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 159.2,
"decode_tok_s": 41.27,
"decoded": 256,
"prefill_tok_s": 1244.55,
"ttft_s": 104.81,
"spec_accept": 0.694,
"tokens_per_round": 2.73,
"vram_slots": 2292
},
{
"model": "Coder",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 34.5,
"decode_tok_s": 56.94,
"decoded": 256,
"prefill_tok_s": 660.44,
"ttft_s": 2.42,
"spec_accept": 0.74,
"tokens_per_round": 2.91,
"vram_slots": 2650
},
{
"model": "Coder",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 18.8,
"decode_tok_s": 53.65,
"decoded": 256,
"prefill_tok_s": 1521.84,
"ttft_s": 3.21,
"spec_accept": 0.736,
"tokens_per_round": 2.91,
"vram_slots": 2615
},
{
"model": "Coder",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 33.1,
"decode_tok_s": 54.85,
"decoded": 256,
"prefill_tok_s": 1870.76,
"ttft_s": 17.7,
"spec_accept": 0.806,
"tokens_per_round": 3.11,
"vram_slots": 2418
},
{
"model": "Coder",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 50.2,
"decode_tok_s": 48.46,
"decoded": 256,
"prefill_tok_s": 1937.69,
"ttft_s": 33.99,
"spec_accept": 0.653,
"tokens_per_round": 2.56,
"vram_slots": 2382
},
{
"model": "Coder",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 90.3,
"decode_tok_s": 42.75,
"decoded": 256,
"prefill_tok_s": 1778.66,
"ttft_s": 73.12,
"spec_accept": 0.588,
"tokens_per_round": 2.27,
"vram_slots": 2326
},
{
"model": "IQ2_XS",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 43.6,
"decode_tok_s": 77.16,
"decoded": 256,
"prefill_tok_s": 523.77,
"ttft_s": 3.46,
"spec_accept": 0.811,
"tokens_per_round": 3.02,
"vram_slots": 4361
},
{
"model": "IQ2_XS",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 43.7,
"decode_tok_s": 74.58,
"decoded": 256,
"prefill_tok_s": 1196.03,
"ttft_s": 4.47,
"spec_accept": 0.824,
"tokens_per_round": 3.36,
"vram_slots": 4309
},
{
"model": "IQ2_XS",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 59.6,
"decode_tok_s": 67.46,
"decoded": 256,
"prefill_tok_s": 1798.65,
"ttft_s": 19.06,
"spec_accept": 0.789,
"tokens_per_round": 3.02,
"vram_slots": 4030
},
{
"model": "IQ2_XS",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 81.4,
"decode_tok_s": 63.52,
"decoded": 256,
"prefill_tok_s": 1610.73,
"ttft_s": 41.37,
"spec_accept": 0.705,
"tokens_per_round": 2.6,
"vram_slots": 3978
},
{
"model": "IQ2_XS",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 126.0,
"decode_tok_s": 59.11,
"decoded": 256,
"prefill_tok_s": 1494.53,
"ttft_s": 87.51,
"spec_accept": 0.704,
"tokens_per_round": 2.71,
"vram_slots": 3897
},
{
"model": "IQ3_XXS",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 48.6,
"decode_tok_s": 65.12,
"decoded": 256,
"prefill_tok_s": 471.53,
"ttft_s": 3.65,
"spec_accept": 0.876,
"tokens_per_round": 4.08,
"vram_slots": 3281
},
{
"model": "IQ3_XXS",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 48.5,
"decode_tok_s": 62.33,
"decoded": 256,
"prefill_tok_s": 974.3,
"ttft_s": 5.17,
"spec_accept": 0.826,
"tokens_per_round": 3.15,
"vram_slots": 3243
},
{
"model": "IQ3_XXS",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 64.9,
"decode_tok_s": 49.83,
"decoded": 256,
"prefill_tok_s": 1554.53,
"ttft_s": 21.84,
"spec_accept": 0.766,
"tokens_per_round": 2.91,
"vram_slots": 3009
},
{
"model": "IQ3_XXS",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 91.7,
"decode_tok_s": 53.58,
"decoded": 256,
"prefill_tok_s": 1449.37,
"ttft_s": 45.84,
"spec_accept": 0.748,
"tokens_per_round": 2.8,
"vram_slots": 2965
},
{
"model": "IQ3_XXS",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 140.9,
"decode_tok_s": 46.97,
"decoded": 256,
"prefill_tok_s": 1386.04,
"ttft_s": 94.33,
"spec_accept": 0.607,
"tokens_per_round": 2.47,
"vram_slots": 2901
}
]
@@ -0,0 +1,36 @@
# Speed with engine 0.1.26
RTX 5070 12 GB (PCIe 5.0 x16), Ryzen 5 7600, 64 GB DDR5-5200, Windows 10. The ready-made 0.1.26 engine, one-shot runs
with what setup writes: `--prefill auto`, the shipped expert profile, `--expert-cache auto`, 8-bit KV above 4K, KV
streaming (`--kv-resident 32768`) from 64K, MTP (`--spec 4 --spec-min-p 0.5`), greedy, 256 generated tokens, images
off, no timing marks. The same code-agent prompts as `2026-09-29-speed-0122`. Every run: [`matrix.json`](matrix.json).
## Prompt (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K |
| --- | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 536 | 1,299 | 2,171 | 2,126 | 2,107 |
| **IQ2_XS** | 534 | 1,256 | 2,092 | 1,754 | 1,752 |
| **IQ3_XXS** | 482 | 1,007 | 1,745 | 1,609 | 1,602 |
| **IQ3_S** | 427 | 913 | 1,624 | 1,640 | 1,443 |
| **Coder** | 656 | 1,583 | 2,177 | 2,236 | 2,208 |
## Output (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K |
| --- | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 87.3 | 93.0 | 81.8 | 76.2 | 73.7 |
| **IQ2_XS** | 79.6 | 78.6 | 76.3 | 63.7 | 62.7 |
| **IQ3_XXS** | 61.9 | 61.6 | 58.5 | 57.2 | 49.0 |
| **IQ3_S** | 52.4 | 53.3 | 48.3 | 46.3 | 45.5 |
| **Coder** | 58.9 | 55.1 | 54.9 | 53.2 | 43.0 |
- Against 0.1.22 (`2026-09-29-speed-0122`), prompts at 32K-128K are 8-28% faster: Q2_0 1,844 -> 2,171 at 32K and
1,682 -> 2,107 at 128K, the Coder 1,779 -> 2,208 at 128K. The gains come from:
- 0.1.24's QSA selection on tensor cores;
- 0.1.25's grouping tables in mapped memory and fused norms;
- 0.1.26's batched draft-layer pass.
- 262K was not run again (0.1.22: Q2_0 1,304, IQ2_XS 1,181 tokens/s).
- Output speed is the same decode as before. One run per cell moves several percent with the text (the share of
accepted drafts, `spec_accept` in `matrix.json`). For example, the Coder's 128K answer accepted 55% of its drafts
and its 64K answer 71%.
@@ -0,0 +1,352 @@
[
{
"model": "Q2_0",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 23.9,
"decode_tok_s": 87.33,
"decoded": 256,
"prefill_tok_s": 536.42,
"ttft_s": 3.4,
"spec_accept": 0.681,
"tokens_per_round": 2.72,
"vram_slots": 4174
},
{
"model": "Q2_0",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 27.6,
"decode_tok_s": 92.97,
"decoded": 256,
"prefill_tok_s": 1299.44,
"ttft_s": 4.21,
"spec_accept": 0.838,
"tokens_per_round": 3.23,
"vram_slots": 4121
},
{
"model": "Q2_0",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 40.8,
"decode_tok_s": 81.75,
"decoded": 256,
"prefill_tok_s": 2170.85,
"ttft_s": 16.02,
"spec_accept": 0.788,
"tokens_per_round": 2.99,
"vram_slots": 3832
},
{
"model": "Q2_0",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 57.3,
"decode_tok_s": 76.23,
"decoded": 256,
"prefill_tok_s": 2126.03,
"ttft_s": 32.06,
"spec_accept": 0.707,
"tokens_per_round": 2.8,
"vram_slots": 3778
},
{
"model": "Q2_0",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 87.7,
"decode_tok_s": 73.73,
"decoded": 256,
"prefill_tok_s": 2106.85,
"ttft_s": 62.48,
"spec_accept": 0.679,
"tokens_per_round": 2.71,
"vram_slots": 3695
},
{
"model": "IQ2_XS",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 42.2,
"decode_tok_s": 79.62,
"decoded": 256,
"prefill_tok_s": 533.83,
"ttft_s": 3.51,
"spec_accept": 0.774,
"tokens_per_round": 2.89,
"vram_slots": 4359
},
{
"model": "IQ2_XS",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 40.6,
"decode_tok_s": 78.6,
"decoded": 256,
"prefill_tok_s": 1256.02,
"ttft_s": 4.31,
"spec_accept": 0.81,
"tokens_per_round": 3.29,
"vram_slots": 4308
},
{
"model": "IQ2_XS",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 53.6,
"decode_tok_s": 76.34,
"decoded": 256,
"prefill_tok_s": 2092.02,
"ttft_s": 16.57,
"spec_accept": 0.85,
"tokens_per_round": 3.2,
"vram_slots": 4029
},
{
"model": "IQ2_XS",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 78.3,
"decode_tok_s": 63.69,
"decoded": 256,
"prefill_tok_s": 1753.65,
"ttft_s": 38.08,
"spec_accept": 0.636,
"tokens_per_round": 2.59,
"vram_slots": 3977
},
{
"model": "IQ2_XS",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 115.1,
"decode_tok_s": 62.72,
"decoded": 256,
"prefill_tok_s": 1751.79,
"ttft_s": 74.85,
"spec_accept": 0.696,
"tokens_per_round": 2.6,
"vram_slots": 3896
},
{
"model": "IQ3_XXS",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 46.9,
"decode_tok_s": 61.94,
"decoded": 256,
"prefill_tok_s": 482.04,
"ttft_s": 3.67,
"spec_accept": 0.845,
"tokens_per_round": 3.57,
"vram_slots": 3279
},
{
"model": "IQ3_XXS",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 47.4,
"decode_tok_s": 61.64,
"decoded": 256,
"prefill_tok_s": 1007.48,
"ttft_s": 5.03,
"spec_accept": 0.801,
"tokens_per_round": 3.19,
"vram_slots": 3242
},
{
"model": "IQ3_XXS",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 65.6,
"decode_tok_s": 58.49,
"decoded": 256,
"prefill_tok_s": 1745.16,
"ttft_s": 19.63,
"spec_accept": 0.789,
"tokens_per_round": 2.85,
"vram_slots": 3007
},
{
"model": "IQ3_XXS",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 88.5,
"decode_tok_s": 57.21,
"decoded": 256,
"prefill_tok_s": 1609.31,
"ttft_s": 41.45,
"spec_accept": 0.74,
"tokens_per_round": 2.87,
"vram_slots": 2963
},
{
"model": "IQ3_XXS",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 127.0,
"decode_tok_s": 48.95,
"decoded": 256,
"prefill_tok_s": 1602.33,
"ttft_s": 81.71,
"spec_accept": 0.643,
"tokens_per_round": 2.37,
"vram_slots": 2899
},
{
"model": "IQ3_S",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 54.2,
"decode_tok_s": 52.4,
"decoded": 256,
"prefill_tok_s": 426.6,
"ttft_s": 4.03,
"spec_accept": 0.822,
"tokens_per_round": 3.17,
"vram_slots": 2617
},
{
"model": "IQ3_S",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 58.9,
"decode_tok_s": 53.3,
"decoded": 256,
"prefill_tok_s": 912.99,
"ttft_s": 5.58,
"spec_accept": 0.85,
"tokens_per_round": 3.38,
"vram_slots": 2579
},
{
"model": "IQ3_S",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 72.8,
"decode_tok_s": 48.31,
"decoded": 256,
"prefill_tok_s": 1624.07,
"ttft_s": 21.07,
"spec_accept": 0.827,
"tokens_per_round": 3.0,
"vram_slots": 2383
},
{
"model": "IQ3_S",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 93.3,
"decode_tok_s": 46.25,
"decoded": 256,
"prefill_tok_s": 1640.34,
"ttft_s": 40.77,
"spec_accept": 0.693,
"tokens_per_round": 2.56,
"vram_slots": 2346
},
{
"model": "IQ3_S",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 144.6,
"decode_tok_s": 45.51,
"decoded": 256,
"prefill_tok_s": 1443.21,
"ttft_s": 90.72,
"spec_accept": 0.697,
"tokens_per_round": 2.73,
"vram_slots": 2291
},
{
"model": "Coder",
"tier": "1k",
"prompt_tokens": 1017,
"exit": 0,
"wall_s": 37.0,
"decode_tok_s": 58.91,
"decoded": 256,
"prefill_tok_s": 656.36,
"ttft_s": 2.48,
"spec_accept": 0.743,
"tokens_per_round": 2.91,
"vram_slots": 2649
},
{
"model": "Coder",
"tier": "4k",
"prompt_tokens": 3562,
"exit": 0,
"wall_s": 18.3,
"decode_tok_s": 55.1,
"decoded": 256,
"prefill_tok_s": 1583.1,
"ttft_s": 3.09,
"spec_accept": 0.713,
"tokens_per_round": 2.88,
"vram_slots": 2614
},
{
"model": "Coder",
"tier": "32k",
"prompt_tokens": 31566,
"exit": 0,
"wall_s": 30.4,
"decode_tok_s": 54.89,
"decoded": 256,
"prefill_tok_s": 2177.17,
"ttft_s": 15.33,
"spec_accept": 0.731,
"tokens_per_round": 2.75,
"vram_slots": 2417
},
{
"model": "Coder",
"tier": "64k",
"prompt_tokens": 64162,
"exit": 0,
"wall_s": 44.8,
"decode_tok_s": 53.22,
"decoded": 256,
"prefill_tok_s": 2236.49,
"ttft_s": 29.51,
"spec_accept": 0.709,
"tokens_per_round": 2.68,
"vram_slots": 2382
},
{
"model": "Coder",
"tier": "128k",
"prompt_tokens": 128478,
"exit": 0,
"wall_s": 75.9,
"decode_tok_s": 43.04,
"decoded": 256,
"prefill_tok_s": 2208.27,
"ttft_s": 59.02,
"spec_accept": 0.553,
"tokens_per_round": 2.23,
"vram_slots": 2324
}
]
+80
View File
@@ -0,0 +1,80 @@
# 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
# 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).
# The other RDNA3 / RDNA4 wave32 chips have the same LDS limit and dot4 instruction and build the same code, but
# the maintainers have not run them (community reports: gfx1102 #192, gfx1200 #176).
set(_strata_hip_validated gfx1100 gfx1201)
set(_strata_hip_unvalidated gfx1101 gfx1102 gfx1200)
set(STRATA_HIP_ARCH_LIST "")
foreach(_arch IN LISTS CMAKE_HIP_ARCHITECTURES)
string(REGEX REPLACE ":.*$" "" _base "${_arch}") # gfx1100:xnack- -> gfx1100
if(_base IN_LIST _strata_hip_validated)
elseif(_base IN_LIST _strata_hip_unvalidated)
message(WARNING "Strata HIP: ${_base} builds, but it is not validated on a real card yet; please report results")
else()
message(FATAL_ERROR
"Strata HIP supports wave32 gfx1100 and gfx1201 (unvalidated: ${_strata_hip_unvalidated}); "
"CMAKE_HIP_ARCHITECTURES is '${CMAKE_HIP_ARCHITECTURES}'")
endif()
list(APPEND STRATA_HIP_ARCH_LIST "${_base}")
endforeach()
list(REMOVE_DUPLICATES STRATA_HIP_ARCH_LIST)
if(NOT STRATA_HIP_ARCH_LIST)
message(FATAL_ERROR "Strata HIP: CMAKE_HIP_ARCHITECTURES is empty")
endif()
# The compiled architectures reach the runtime device check (src/core/device.cu) as "gfx1100,gfx1201": a binary
# carried to a card it has no code for stops at startup with a clear message instead of "invalid device function".
string(REPLACE ";" "," STRATA_HIP_ARCHS "${STRATA_HIP_ARCH_LIST}")
enable_language(HIP)
find_package(hip CONFIG REQUIRED)
find_package(hipblas CONFIG REQUIRED)
find_package(hipblaslt CONFIG QUIET)
if(NOT TARGET hip::host)
message(FATAL_ERROR "The ROCm hip CMake package did not provide hip::host")
endif()
if(NOT TARGET roc::hipblas)
message(FATAL_ERROR "The ROCm hipblas CMake package did not provide roc::hipblas")
endif()
if(TARGET roc::hipblaslt)
set(STRATA_HIPBLASLT_AVAILABLE ON)
else()
set(STRATA_HIPBLASLT_AVAILABLE OFF)
message(STATUS "Strata: hipBLASLt not found; solution-table dispatch is unavailable")
endif()
# HIP's link step produces a PIE; make Strata and ggml objects PIC for the ROCm linker.
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(STRATA_HIP_COMPAT_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/strata/hip_compat")
add_library(strata_hip_runtime INTERFACE)
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)
target_compile_options(strata_hip_runtime INTERFACE
"$<$<COMPILE_LANGUAGE:${_language}>:-include>"
"$<$<COMPILE_LANGUAGE:${_language}>:${STRATA_HIP_COMPAT_INCLUDE_DIR}/cuda_runtime.h>")
endforeach()
# CMake does not infer HIP from Strata's existing CUDA-shaped .cu suffixes.
file(GLOB_RECURSE _strata_hip_sources CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/*.cu"
"${CMAKE_CURRENT_SOURCE_DIR}/bench/*.cu"
"${CMAKE_CURRENT_SOURCE_DIR}/tests/*.cu")
if(_strata_hip_sources)
set_source_files_properties(${_strata_hip_sources} PROPERTIES LANGUAGE HIP)
endif()
foreach(_source IN ITEMS tests/hip/intrinsics.cpp tests/hip/native_qsa_score.cpp)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_source}")
set_source_files_properties("${_source}" PROPERTIES LANGUAGE HIP)
endif()
endforeach()
message(STATUS "Strata: HIP enabled, arch ${STRATA_HIP_ARCHS}")
Binary file not shown.
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
#!/bin/sh
# Entry point for the Strata container. The engine is compiled during docker
# build and lives in the image, so the first start only downloads the model.
# The install config is kept on the /data volume so a recreated container skips
# the setup pass and goes straight to serving.
set -e
cd /opt/strata || exit 1
STRATA_DATA="${STRATA_DATA:-/data}"
FAMILY="${FAMILY:-qwen}"
MODEL="${MODEL:-IQ2_XS}"
CONTEXT="${CONTEXT:-32768}"
VISION="${VISION:-no}" # no | yes | cpu (the image encoder on the CPU)
HOST="${HOST:-0.0.0.0}"
PORT="${PORT:-8080}"
API_KEY="${API_KEY:-}"
KV="${KV:-}" # int8 | q4_0 | k8v4; empty: setup.py's own default (int8)
GPUS="${GPUS:-}" # "0,2" or "all": one model across several cards (docs/MULTI_GPU.md)
GPU="${GPU:-}" # one card, numbered as nvidia-smi numbers them
LAYER_SPLIT="${LAYER_SPLIT:-}" # with GPUS: where each later card's layers start (default: auto)
LOW_RAM="${LOW_RAM:-auto}" # on: the experts come from the pack's experts.bin, not from RAM
# setup.py starts the newest strata-*.json it finds, so link in exactly the one
# this family and model were set up with. The config is the recorded output of
# that setup (the pack, the profile, the quant, the KV decision), not settings
# the entry point could rebuild from env vars. qwen has an empty family tag.
case "$FAMILY" in qwen) prefix="" ;; *) prefix="${FAMILY}-" ;; esac
tag="${prefix}$(printf '%s' "$MODEL" | tr 'A-Z' 'a-z')"
cfg="$STRATA_DATA/config/strata-$tag.json"
mkdir -p "$STRATA_DATA/config"
# REINSTALL is only needed to change settings for a model that is already set up
# (context, vision, KV, host, api_key). Switching between models already on the
# volume needs no setup pass: their config is already there.
#
# KV / GPUS / GPU / LAYER_SPLIT are passed only when set, so an unset one keeps
# setup.py's own default. LOW_RAM is always passed: setup.py measures the PC's RAM
# from /proc/meminfo, which in a container is the host's total, not the container's
# limit, so a memory-capped container has to ask for the low-RAM mode itself.
if [ "${REINSTALL:-0}" = "1" ] || [ ! -f "$cfg" ]; then
echo "Setting up $tag: downloading the model (~70 GB; the engine is already in the image)."
set -- --family "$FAMILY" --model "$MODEL" --context "$CONTEXT" --vision "$VISION" \
--data-dir "$STRATA_DATA" --host "$HOST" --api-key "$API_KEY" \
--port "$PORT" --no-start --low-ram "$LOW_RAM"
if [ -n "$KV" ]; then set -- "$@" --kv "$KV"; fi
if [ -n "$GPUS" ]; then set -- "$@" --gpus "$GPUS"; fi
if [ -n "$GPU" ]; then set -- "$@" --gpu "$GPU"; fi
if [ -n "$LAYER_SPLIT" ]; then set -- "$@" --layer-split "$LAYER_SPLIT"; fi
.venv/bin/python setup.py --setup --yes "$@"
[ -e "/opt/strata/strata-$tag.json" ] && { cmp -s "/opt/strata/strata-$tag.json" "$cfg" || cp -f "/opt/strata/strata-$tag.json" "$cfg"; }
else
[ -e "/opt/strata/strata-$tag.json" ] || ln -s "$cfg" "/opt/strata/strata-$tag.json"
fi
# Later starts skip straight here: setup.py finds the installed config and
# launches serve/server.py (OpenAI- and Anthropic-compatible API on :8080).
# GPUS / GPU / LAYER_SPLIT are repeated on purpose. Given at the start they pin the
# cards for this model, and setup.py saves them in its config; without them a config
# that names one card is offered once to a pair, on its own, when the host has two
# cards that can share the model (setup.py's offer_together, docs/MULTI_GPU.md).
set -- --port "$PORT"
if [ -n "$GPUS" ]; then set -- "$@" --gpus "$GPUS"; fi
if [ -n "$GPU" ]; then set -- "$@" --gpu "$GPU"; fi
if [ -n "$LAYER_SPLIT" ]; then set -- "$@" --layer-split "$LAYER_SPLIT"; fi
exec .venv/bin/python setup.py "$@"
+240
View File
@@ -0,0 +1,240 @@
# Experimental AMD HIP backend (gfx1100, gfx1201)
This is a Linux source build for the RX 7900 XT / XTX (RDNA3, gfx1100) and the
RX 9070 / 9070 XT / Radeon AI PRO R9700 (RDNA4, gfx1201; see [RDNA4](#rdna4-gfx1201)).
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.
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
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`).
This does not claim bit-identical model answers across backends. See
[performance settings and evidence](AMD_HIP_PERFORMANCE.md).
## Install with setup (recommended)
On Linux with an RX 7900 XT / XTX or an RX 9070 / 9070 XT / Radeon AI PRO R9700 and the kernel's amdgpu driver
(no ROCm install needed):
```sh
./setup.sh --backend hip
```
- **Detection:** setup finds the card through the kernel's KFD topology. Integrated Radeon GPUs are listed as not
supported. On a PC without an NVIDIA card Strata can use, `--backend hip` is chosen automatically.
- **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,
`gfx120X-all` for gfx1201 (`STRATA_ROCM_VERSION` / `STRATA_ROCM_INDEX` override them).
- **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`).
- **hipBLASLt tuning table:** setup uses `tools/hip/<arch>-hipblaslt-<version>.txt` only when it matches both the
card's architecture and the installed hipBLASLt version (read from `hipblaslt-version.h`; 1.2.0 is `100200`).
Otherwise it says so and the prompt's dense matrix products use plain hipBLAS (slower prompts, same answers).
A table's solution ids are valid only for that pair, and the engine refuses any other table.
- **Limits for now:** one GPU, no images, no calibration. The Monitor shows no GPU statistics.
The rest of setup is the same as on NVIDIA: the model download, the start script, the server.
## Build
Requirements: a working ROCm driver/runtime, HIP development headers and
compiler, hipBLAS and (for tuned dense prefill) hipBLASLt development files, CMake 3.24+, a C++20 host compiler, and Git.
The model still needs sufficient system RAM and fast SSD storage for its PLE
table. VRAM occupancy alone is not a throughput measurement.
```sh
cmake -S . -B build-hip \
-DCMAKE_BUILD_TYPE=Release \
-DSTRATA_ENABLE_HIP=ON -DSTRATA_ENABLE_CUDA=OFF \
-DCMAKE_HIP_ARCHITECTURES=gfx1100
cmake --build build-hip --target strata -j2
```
`CMAKE_HIP_ARCHITECTURES` is `gfx1100`, `gfx1201`, or a list such as `"gfx1100;gfx1201"` (one binary for both).
gfx1101, gfx1102 and gfx1200 (the same wave32, 64 KiB LDS and dot4 instruction) build with a warning: they have
not been validated on a real card here. 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".
If CMake cannot find the HIP compiler, add
`-DCMAKE_HIP_COMPILER=/path/to/rocm/llvm/bin/clang++`. On the Fedora-family test
host this was `/usr/lib64/rocm/llvm/bin/clang++`. Use the compiler and libraries
from the same ROCm installation. Do not enable both GPU backends in one build.
Native IQ experts are enabled by default. CMake fetches the llama.cpp revision
pinned by this repository. For an offline build, point `STRATA_GGML_DIR` at a
checkout of that exact revision; using an arbitrary newer checkout changes the
dependency being tested.
## Model and serving configuration
Prepare a supported model pack and its MTP runtime using the existing tools.
For OrcaRouter IQ3_XXS, follow [the explicit compatibility conversion](ORCA.md);
this backend does not change quantization, model licenses, or tokenizers.
Add `--experts-bin` to the `tools/iq_pack.py` command: mmap requires the pack's
`experts.bin`, which the default native packing command does not emit. This
consumes additional disk space. Original GSQ-RCO IQ3_XXS uses shard 2 for PLE;
Orca uses shard 1. Keep each model's own pack and tokenizer together.
Use `build-hip/strata` as the executable in the server JSON. Select the AMD
device with `ROCR_VISIBLE_DEVICES`/`HIP_VISIBLE_DEVICES` if necessary. Start with
a modest context and prefill chunk size before measuring larger workloads.
Keep the PLE file on an SSD. Do not assume a configured context length proves
successful full-window inference.
Large pinned host allocations can fail on ROCm even when ordinary RAM is
available. The existing `--mmap-experts` path avoids allocating the full pinned
expert arena; it still depends on OS file-cache residency and may stall on
storage reads. It does not make SSD access equivalent to RAM.
Starting args for the original GSQ-RCO IQ3_XXS model (replace the paths):
```sh
build-hip/strata --serve \
--pack packs/iq3xxs \
--native /path/to/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00001-of-00002.gguf \
--ple-gguf /path/to/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_XXS-00002-of-00002.gguf \
--mmap-experts --expert-profile data/expert-profile.bin --expert-cache auto \
--prefill 512 --spec 4 --spec-min-p 0.5 --mtp mtp/rt \
--max-context 4096 --kv int8 --pool-workers 15 \
--adapt-every 0 --pcie-frac 0 --vram-reserve-mib 1024
```
`--serve` is the engine's internal token protocol. For a browser or OpenAI API,
put these args in the `args` array of the [server JSON example](ORCA.md#local-server),
set `exe` to `build-hip/strata`, and use the matching pack's `tokenizer` directory.
Launch with `.venv/bin/python -m serve.server --engine strata --config strata-hip.json --port 8080`.
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.
Setup installs one AMD card; the engine's layer split also runs on two AMD cards when the config is written by hand
(see RDNA4 below).
## RDNA4 (gfx1201)
The RX 9070 / 9070 XT and the Radeon AI PRO R9700 run the same kernels as gfx1100: wave32, 64 KiB of LDS per
workgroup, the signed dot4 instruction (`v_dot4_i32_iu8` through `__builtin_amdgcn_sudot4`) and a 100 MHz wall
clock. The first report and patch came from doplxyz (#178). Validated on 2026-09-30 on doplxyz's test machine:
an RX 9070 XT 16 GB and a Radeon AI PRO R9700 32 GB (both gfx1201), a Ryzen 9 3900X (16 threads, no AVX-512),
47 GB RAM, Ubuntu 24.04 in a KVM/VFIO guest.
- **Build:** complete HIP build with tests (`-DCMAKE_HIP_ARCHITECTURES=gfx1201 -DSTRATA_BUILD_TESTS=ON
-DSTRATA_PREFILL_MMQ=ON`), with the system ROCm 7.14 (hipBLASLt 1.4.1) and with setup's own path: TheRock
7.10.0a20251120 wheels from `gfx120X-all` (hipBLASLt 1.2.0) and `build_engine_hip`.
- **ctest** (all 45 registered tests, R9700): 42 pass, `hip_prefill_hipblaslt_gemm` skips (no table), and two
fail for reasons outside the GPU: `ple_parity` needs the Q2_0 model fixture, `expert_multi_test` needs an
AVX-512 CPU. `hip_handoff` needed the volatile ring store: on gfx1201 a plain store to mapped pinned memory
stays in the GPU's L2 until the stream is synchronized.
- **Arch check:** a gfx1100-only build stops on the gfx1201 card at startup with the message above (engine and
`strata-device`).
- **End to end** (Coder IQ1_M, setup's arguments: `--expert-cache auto --prefill auto --spec 4 --spec-min-p 0.5`,
MTP, `--kv int8`, `--max-context 32768`; 128 greedy tokens, system ROCm 7.14, no hipBLASLt table). The answers
are coherent and the same on both cards. The live server smoke (`serve/server.py`: web page, models, props,
chat, prefix reuse, streaming, the Anthropic endpoint, a sampled code answer) passed 9/9 on each card.
| card | expert cache | peak VRAM | 4K prompt | 4K decode | 16K prompt | 16K decode |
|---|---|---|---|---|---|---|
| Radeon AI PRO R9700 32 GB | 12,288 slots, 23.4 GiB | 29.7 GiB | 982 tok/s | 45.5 tok/s | 1,402 tok/s | 48.3 tok/s |
| RX 9070 XT 16 GB | 4,931 slots, 9.4 GiB | 15.6 GiB | 782 tok/s | 30.8 tok/s | 1,235 tok/s | 35.4 tok/s |
| R9700, TheRock 7.10 wheels | 12,288 slots | | 957 tok/s | 44.2 tok/s | 1,284 tok/s | 43.9 tok/s |
The engine's resident memory was about 26 GB in every run.
- **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.
- **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 does not offer it yet. 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
16K prompts); checkpoints on the split (second turn, rewind, a prompt sharing a prefix, a cancelled prompt
retried) give exactly the tokens of a fresh read. The conversation cache refuses a split at start (exit 2).
On this pair the split does not pay: the R9700 alone already holds all 12,288 expert pairs.
| run (the same session, warm) | 4K prompt | 16K prompt | decode |
|---|---|---|---|
| R9700 alone | 1,794 tok/s | 1,804 tok/s | 51-52 tok/s |
| R9700 + 9070 XT, auto split | 1,384 tok/s | 1,906 tok/s | 42-43 tok/s |
| RX 9070 XT alone | 1,016 tok/s | 1,519 tok/s | 38-40 tok/s |
A split is worth it when no single card holds the model's experts. On Linux the split pins at most 8 GiB of the
expert arena (a Windows limit that also applies here).
- **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:** gfx1200 (RX 9060 XT; a community report is #176), images, long contexts beyond 16K,
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
is calibrated on the card with the ROCm the engine runs with. The shipped gfx1100 table's rows are the engine's
dense GEMM shapes; to calibrate them for another card or version (a few minutes):
```sh
cmake --build build-hip --target tune_hipblaslt
CASES=$(awk 'NR>2 {printf " --case %s,%s,%s,%s,%s", $1, $5, $2, $3, $4}' tools/hip/gfx1100-hipblaslt-100200.txt)
./build-hip/tune_hipblaslt $CASES --tuning-out table.txt
```
The file's second line names the architecture and version (`STRATA_HIPBLASLT_TUNING_V1 gfx1201 100401`); save it
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.
## Original backend validation (PR #94)
The following is historical validation of the original backend, not a fresh
test count for this replacement. Current build/test evidence and performance
limits are recorded in [AMD_HIP_PERFORMANCE.md](AMD_HIP_PERFORMANCE.md).
Tested against upstream `c1e903310f211e6630780c3bd2038778c071c68d` (0.1.20),
with pinned llama.cpp `3cf03257f219afbe7334045ff7c6a06ac68c627d`.
The host has an RX 7900 XTX (24 GiB), Ryzen 9 7950X3D, 64 GiB system RAM,
and Fedora-family Linux with ROCm 7.1.52802 / Clang 20. The original model used
for the smoke check was on mechanical RAID0; cold page faults were slow. Keep
latency-sensitive data on SSD and measure warm residency separately.
- Complete HIP Release build, including the executable and parity targets.
- All 25 tests selected by the command below passed on 2026-09-29.
- Intrinsic parity includes signed packed-byte dot products and overflow.
- CPU/GPU mapped-memory handoff covers delayed publication, changing payloads,
and repeated graph replay in both directions.
- GR coverage includes the maximum eight-token fused batch and graph replay
after inputs change. Native QSA checks the scalar HIP fallback.
- Q8_K quantization is byte-exact against the existing reference. HIP's
`__fmul_rn` can become ordinary multiplication; disabling contraction in
`quantize_act.cu` preserves the separately rounded product before the magic
rounding bias is added. CUDA's compile flags remain unchanged.
- The mmap regression covers canonical and differing native layer sizes,
truncated/oversized files, lookup bounds, and reopening after errors.
- CUDA 13 / GCC 15 / sm_89 executable build passed; GR, sampler, quantization,
and mmap tests passed on the same tree. This is a regression check, not a
claim of complete CUDA inference validation.
- CPU-only `strata-plan` build passed without either GPU backend.
- Real GSQ-RCO IQ3_XXS server smoke with mmap, the shipped expert profile,
automatic GPU cache, and MTP passed arithmetic, executable Python addition,
system-marker recall, and 1,170-token batched-prefill recall. All four requests
ended normally with correct answers; one engine process served them all.
The cache held 11,142 experts (18.08 GiB), with 874 MiB of VRAM free after
startup. These short checks do not establish a throughput or quality benchmark.
Build all targets before running the registered focused checks:
```sh
cmake --build build-hip -j2
ctest --test-dir build-hip --output-on-failure --timeout 60 \
-E '^(ple_parity|platform_memory_test)$'
```
`ple_parity` requires an external model fixture. `platform_memory_test` requests
256 MiB of locked memory; the test shell's 8 MiB memlock limit prevented that
test from passing. These are explicit exclusions, not skipped tests counted as
passes. Additional unpublished upstream fixture suites are not covered.
The published performance table explicitly identifies the measured development
snapshot; it must not be read as a benchmark of every subsequent rebase. Vision, long-context stress, broad answer-quality
equivalence, other AMD cards, and mixed-vendor inference are not validated here.
+145
View File
@@ -0,0 +1,145 @@
# RX 7900 XTX support and performance evidence
This opt-in Linux `gfx1100` backend supersedes the initial support in
[PR #94](https://github.com/Niko1221/Strata/pull/94). It retains HIP runtime and
wave32 integer-dot compatibility, native mmap layout validation, and MTP, then
adds HIP MMQ, optional calibrated dense hipBLASLt GEMM, native prefill batching,
and the host-memory / SSD paths used by the measured configuration. HIP blocking
expert uploads use one reusable pinned staging buffer, including prompt-cache
refills, to avoid repeated pageable-source registrations.
## Reproduce the configuration
Build instructions are in [AMD_HIP.md](AMD_HIP.md). Use the repository-pinned
llama.cpp dependency; do not silently substitute another revision. Build with
`STRATA_PREFILL_MMQ=ON` and use these runtime variables for the measured arm:
```sh
export STRATA_PREFILL_MMQ=1
export STRATA_HIPBLASLT_TUNING="$PWD/tools/hip/gfx1100-hipblaslt-100100.txt"
export STRATA_PREFILL_RING=96
export STRATA_IO_THREADS=32
```
The supplied table is calibrated for gfx1100 and hipBLASLt version 100100.
It is not a universal ROCm tuning table. The runtime guards architecture,
library version and actual shape/stride/workspace requirements, falling back
when a table entry is unavailable or incompatible. HIP MMQ is opt-in at runtime. Default CUDA selection is preserved.
Measured engine configuration: Orca Flash Next IQ3_XXS, native pack plus matching
GGUF/tokenizer/template, `--mmap-experts --resident-cpu-experts`, fixed ranked
`--expert-profile`, `--expert-cache auto`, `--prefill 8192`, `--spec 4`,
`--spec-min-p 0.5`, matching MTP runtime, `--max-context 262144`, `--kv int8`,
`--kv-resident 32768`, `--pool-workers 15`, `--adapt-every 0`, `--pcie-frac 0`,
and `--vram-reserve-mib 1024`. Keep PLE on SSD.
`--resident-cpu-experts` copies the complement of the static GPU cache to ordinary
RAM. It requires mmap and a fixed expert profile. The slots the prompt path may
borrow keep their experts in RAM too as far as the budget below allows (the rest
use the mapped fallback while borrowed and for their refill), and adaptive swaps
(`--adapt-every`) exchange experts between VRAM and the RAM copy without reading
the file. The copy is pageable; `--resident-experts` (CUDA, chosen by setup)
page-locks it and uses 4 GiB of headroom. Allocation needs sufficient available RAM;
on Linux this option requires readable standard cgroup-v2 mounts. For each
finite cgroup ancestor, the guard credits only `inactive_file` after subtracting
`file_dirty` and `file_writeback`, capped by current usage; it remains bounded by
the ancestor limit and host `MemAvailable`, with 8 GiB headroom. This accounts
for reclaimable clean file cache but cannot reserve memory against concurrent
system or process allocations.
The POSIX PLE path issues direct reads through a configurable worker pool.
Use a dedicated idle server, restart it between arms, and capture its engine log:
```sh
python3 tools/hip/bench_prefill.py \
--model MODEL_NAME --url http://127.0.0.1:8080 \
--engine-log /path/to/engine.log --label candidate --output candidate.json
```
The script uses the same synthetic source prompts and request order as the
reported measurements: a small warmup, then 140/280/140/280 functions, with one
follow-up after each fresh prompt. It enforces zero reused tokens for the fresh
prompts and records prefill, decode, wall time and stop reason. It requires the
unbuffered log to contain exactly one completed timing record per request. The
128-token output cap is intentional for throughput measurement, not task success.
Never use cancelled-request timing lines as throughput evidence.
## Final revision benchmark
Measured on 2026-09-29, source revision `9568e78da663e6b224ab96d57baf450717b36e73`.
HIP executable SHA-256: `705f5925a788bc227bad72abfb5466032e0879d752fea9afc3eb32051b35182b`.
The control and candidate below were both measured anew in this session; no
historical benchmark values are mixed into the table. The control binary SHA-256
is `4522d4937ca4a41ca294d31b60df8c2bc4df5b7a6caf65eaad10c036e08491f7`;
its source was an existing local runtime snapshot rather than a clean upstream commit.
Both arms use an RX 7900 XTX 24 GiB / gfx1100, Ryzen 9 7950X3D,
64 GiB installed RAM, Fedora-family Linux, ROCm 7.1 and a 272 W GPU cap.
Other model services are stopped for measurement and restored afterward.
Sampling: temperature 0, top-k 1, top-p 1, min-p 0, seed 42, reasoning disabled.
| Fresh request, execution order | Control prefill t/s | Candidate prefill t/s | Candidate output t/s | Candidate request wall time |
| --- | ---: | ---: | ---: | ---: |
| 4,210 tokens, first use | 240.0 | 447.5 | 55.5 | 11.729 s |
| 8,830 tokens, first use at this size | 484.8 | 873.7 | 55.7 | 12.420 s |
| 4,210 tokens, warmed | 457.2 | 901.1 | 56.7 | 6.937 s |
| 8,830 tokens, warmed | 478.9 | 966.5 | 59.2 | 11.314 s |
Each request generated exactly 128 tokens and finished at the intentional length
cap. All four fresh prompts had zero reused KV tokens. Fresh-prompt prefill was
1.80–2.02x the freshly measured existing AMD runtime; fresh-request wall time was
40–46% lower. This is a comparison with our existing HIP runtime, not unmodified
upstream, which does not provide this backend. The candidate also completed four
cached follow-ups in 2.95–3.26 seconds. Those follow-ups are not apples-to-apples
prefill comparisons: cache reuse differs with generated text and checkpoint
selection. Full sanitized measurements are in
[the fresh-run JSON](benchmarks/2026-09-29-gfx1100.json).
Before this revision, an intermediate package missing the HIP upload staging
buffer stalled on the 8,830-token request and was rejected. Restoring staging
allowed the complete sequence above to finish. That failed attempt is not
included in the throughput table. No watchdog limit was raised to obtain these
results.
Zero KV reuse is not equivalent to a cold filesystem cache. First-use speed
and warmed speed are reported separately. Individual observations do not
establish confidence intervals or a general rate at every context length.
## Correctness and quality limits
Both HIP and CUDA executables built from the measured source revision. CUDA was
compile-checked, not performance-tested by this run. The selected HIP CTest suite
passed **29/29** with the tuning table enabled, including expert-upload readback,
asynchronous handoff, QSA, MMQ, Lt GEMM, KV streaming and PLE reading. The excluded
`ple_parity` requires an external fixture; `platform_memory_test` requires a
larger locked-memory limit than the test account provides. Separately, three
real IQ3_XXS PLE matrix graph replays passed, and the POSIX direct-file test passed
queued reads, short EOF, wake, close/drain and reopen checks.
Development checks passed eight MMQ numerical comparisons, four actual Lt GEMM
comparisons, and native QSA/indexer/embedding parity including tail states,
chunk continuation and image overrides. Real quantized MMQ relative L2 error
was approximately 0.0026 against raw-FP32/dequantized-weight reference, reflecting
Q8 activation arithmetic; this is not bitwise numerical equivalence.
End-to-end coding quality is not established by these numerical checks or capped
throughput requests. Validate completed tasks with independent runtime tests; this
contribution makes no broad answer-quality or agentic-reliability claim.
The changes do not claim better model reasoning, verified full-context behavior,
end-to-end vision validation, Windows HIP, other AMD architectures, or mixed
AMD/NVIDIA execution. Existing CUDA multi-GPU code remains present but is not
validation of HIP multi-GPU support.
## Attribution and rejected experiments
The measurements above were taken before this backend was rebased onto engine 0.1.24. The PR's own batched
embedding gather and QSA indexer append (adapted from
[PR #108](https://github.com/Niko1221/Strata/pull/108), commit
`acd487233c0bbe2217a6881c5bb43f8a283b0de5`) were dropped in the rebase: 0.1.24 already does both on every
backend, bit-exact (C-2, C-4). #108's optional GDN parallel/split path is not included. Existing upstream MMQ orchestration is retained and enabled for
HIP with AMD architecture identification and the correct backend compilation.
Static 16K chunks, expanded FP16 expert tuning, and a 192-slot ring did not offer
a consistent end-to-end win in the evaluated workload. The table includes only
the selected 30 dense-shape rows. Microkernel speedups alone were not sufficient
to select a configuration. No increase in GPU power cap was used.
+195 -18
View File
@@ -12,7 +12,7 @@ New here? Start with the [README](../README.md) - it has everything you need to
## Speed (measured)
RTX 5070 **12 GB**, Ryzen 5 7600 (6 cores), 64 GB DDR5-5200, Windows, engine 0.1.14 with the settings setup writes
RTX 5070 **12 GB**, Ryzen 5 7600 (6 cores), 64 GB DDR5-5200, Windows, engine 0.1.26 with the settings setup writes
(`--prefill auto`, 8-bit KV above 4K, KV streaming from 64K). One code-agent prompt per length, 256 generated tokens,
MTP speculative decoding on. "262K" is the model's full context window (a 259,943-token prompt). The IQ2_XS row was
measured with Swift 1.5's IQ2_XS, which runs at the original's speed.
@@ -21,21 +21,27 @@ measured with Swift 1.5's IQ2_XS, which runs at the original's speed.
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 494 | 1,007 | 1,308 | 1,294 | 1,208 | 967 |
| **IQ2_XS** | 461 | 811 | 1,238 | 1,136 | 1,071 | 886 |
| **IQ3_XXS** | 415 | 770 | 1,108 | 1,065 | 1,015 | - |
| **IQ3_S** | 396 | 737 | 1,070 | 1,070 | 931 | - |
| **Coder** | 599 | 1,152 | 1,298 | 1,350 | 1,266 | 1,034 |
| **Q2_0** | 536 | 1,299 | 2,171 | 2,126 | 2,107 | 1,304† |
| **IQ2_XS** | 534 | 1,256 | 2,092 | 1,754 | 1,752 | 1,181*† |
| **IQ3_XXS** | 482 | 1,007 | 1,745 | 1,609 | 1,602 | - |
| **IQ3_S** | 427 | 913 | 1,624 | 1,640 | 1,443 | - |
| **Coder** | 656 | 1,583 | 2,177 | 2,236 | 2,208 | 1,034** |
Engine 0.1.26; `bench/results/2026-09-29-speed-0126`. At 32K-128K that is 8-28% faster than 0.1.22. † not measured
again: 0.1.22. \* measured with images on (the image encoder's VRAM reserve leaves fewer experts cached). \*\* not
measured again: 0.1.14.
### Output (tokens/s)
| Model | 1K | 4K | 32K | 64K | 128K | 262K |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **Q2_0** | 84.3 | 90.3 | 73.6 | 69.0 | 67.2 | 60.3 |
| **IQ2_XS** | 74.4 | 73.8 | 71.5 | 64.3 | 59.8 | 52.8 |
| **IQ3_XXS** | 60.3 | 62.1 | 51.4 | 50.0 | 45.8 | - |
| **IQ3_S** | 51.5 | 51.6 | 48.2 | 48.8 | 40.5 | - |
| **Coder** | 53.3 | 50.6 | 53.3 | 50.8 | 44.0 | 42.8 |
| **Q2_0** | 87.3 | 93.0 | 81.8 | 76.2 | 73.7 | 60.3† |
| **IQ2_XS** | 79.6 | 78.6 | 76.3 | 63.7 | 62.7 | 52.8† |
| **IQ3_XXS** | 61.9 | 61.6 | 58.5 | 57.2 | 49.0 | - |
| **IQ3_S** | 52.4 | 53.3 | 48.3 | 46.3 | 45.5 | - |
| **Coder** | 58.9 | 55.1 | 54.9 | 53.2 | 43.0 | 42.8† |
Engine 0.1.26, the same runs. † not measured again: 0.1.14.
Output speed depends on the text as well: speculative decoding runs faster when more of the drafted tokens are
accepted, so a different answer to the same prompt moves it by several percent. Run back to back on the 4K prompt,
@@ -58,6 +64,54 @@ halves the KV cache's memory with a Hadamard rotation before 4-bit rounding (PR
is measurably less precise on long documents (perplexity +8-12%; needle tests still pass). 8-bit stays the default.
Details: [`bench/results/2026-09-27-kv-q4`](../bench/results/2026-09-27-kv-q4/README.md).
**Hybrid K8V4 KV cache (engine 0.1.25, optional, PR #120):** `--kv k8v4` (`START-HERE.bat --setup --kv k8v4`) keeps
the keys at 8 bits and stores the values as rotated 4-bit: 23% less KV memory than 8-bit, so more experts fit in
VRAM. RTX 3090, the Coder at 198K context: 99 instead of 85 tokens/s output, the same needle results, prompts 2-5%
slower. It does not stream its KV cache (KV streaming is on by default from 64K), so it pays off mostly on large
cards at long contexts.
**Reproducible greedy output (0.1.30, opt-in, `STRATA_IQ_MT_MIN=1`):** with the IQ models, the CPU computes an
expert for one token with ggml's dot product and for several tokens with Strata's multi-token kernels, which round
slightly differently. How many tokens share an expert depends on the drafts in a verify window, so the same prompt
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.
**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.
**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
experts plus ~10 GB), setup instead maps them from one file in the model's folder (`--mmap-experts`, the pack's
`experts.bin`, +23-50 GB of disk). The OS file cache holds what the GPU does not, and it can give that memory back.
On the Coder the engine's committed memory drops from 36 to ~13 GB, with the same answers. With a big GPU (an RTX
5090 holds all of the Coder's experts, most of Q2_0's) it runs at nearly the usual speed. With a small one, most
experts come from the SSD and it is much slower (setup says so). `START-HERE.bat --setup --low-ram on|off` overrides
the choice.
**Low-RAM mode, resident (engine 0.1.30):** when the experts the GPU does not hold fit the RAM (with the same ~10 GB
beside them), setup picks the resident variant instead (`--resident-experts`): at start the engine copies exactly those
experts from `experts.bin` into RAM (page-locked when the driver allows, else locked in RAM), so while it answers
nothing is read from the SSD, however little RAM the OS leaves for its file cache. Examples with setup's context: a
32 GB PC with a 24 GB GPU runs Q2_0, IQ2_XS and the Coder this way (~16-18 GB of experts in RAM, the GPU holds the
other ~18 GB), a 32 GB PC with a 12-16 GB GPU the Coder; IQ3_XXS on a 32 GB PC stays mapped. The details:
- The prompt path borrows room in the GPU's expert cache for its buffers and puts those experts back after the prompt;
as far as the RAM allows, their experts are kept in RAM too (so a prompt reads nothing from the SSD either).
- The cache still follows the conversation (`--adapt-every`): a swap copies the evicted expert back from VRAM into the
RAM place of the one that replaces it, so the RAM copy keeps holding exactly what the GPU does not.
- The answers are the plain mapped mode's for the same expert placement: the bytes are the file's. With a page-locked
copy the GPU also takes its usual share of the misses over PCIe (`--pcie-frac`), as with enough RAM; `--pcie-frac 0`
(or `STRATA_RESIDENT_PIN=0`) gives the mapped mode's exact tokens.
- The engine leaves 4 GB of the RAM it finds free (`STRATA_RESIDENT_HEADROOM_GIB`); when even the experts the GPU does
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).
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).
@@ -148,7 +202,7 @@ You need **only an NVIDIA driver** (version 580 or newer; update it with the NVI
| | |
| --- | --- |
| GPU | NVIDIA **RTX 30, 40 or 50 series**, **12 GB VRAM or more** (8 GB runs, slowly). Measured on an RTX 5070; RTX 30/40 are untested. |
| 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. |
| 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. |
@@ -160,7 +214,7 @@ elsewhere) finds them and sets itself up the same way. The place is remembered p
`~/.config/strata/settings.json`); `--data-dir` chooses another. Installs from before 0.1.16 are moved there by the next
start (a rename on the same drive; files on another drive are used where they are).
Python 3.12 if you have none (for your user account, no admin), a private Python environment, NVIDIA's CUDA libraries
(from pip, ~0.4 GB), the ready-made Strata engine for RTX 30/40/50, the model and the MTP draft layer. If no
(from pip, ~0.4 GB), the ready-made Strata engine for RTX 20/30/40/50, the model and the MTP draft layer. If no
ready-made engine fits your PC, it offers to install the build tools (Visual Studio Build Tools + CUDA Toolkit on
Windows, `build-essential` + CUDA on Ubuntu) and compiles the engine for your GPU (asks first; 20-40 minutes once).
@@ -216,6 +270,28 @@ install; `START-HERE.bat --calibrate` (Linux: `./setup.sh --calibrate`) does it
speed with each setting and keeps one only when it is more than 3% faster. The result is remembered per PC and model
(in the settings file next to the data folder's record), so updates keep it.
### Running it at startup (Task Scheduler)
To have the model up at logon, people start the serve from **Task Scheduler** (or a service). Beware: Windows
throttles such contexts, and the model's ~40 GB expert load then crawls at **~0.05 GiB/s (13-14 minutes)**
instead of **~1.4-1.5 GiB/s (~35 seconds)** - a 24x slower start. Measured on an RTX 5070 Ti + Ryzen 7 9800X3D
+ NVMe, same binary, same args, same cache state:
| How the serve starts | Expert load |
| --- | ---: |
| Double-click / terminal / SSH | 1.42-1.52 GiB/s (~35 s) |
| Task Scheduler with its defaults | 0.05 GiB/s (821-841 s) |
| Task Scheduler with the two settings below | 1.42 GiB/s (35 s) |
In the task's properties set both of these (the defaults are the opposite):
- **Priority level: Normal** (Options tab; the default is Below normal), and
- **Run with highest privileges** (General tab; without it the task runs with a limited user token - which
also strips `SeLockMemoryPrivilege`, the privilege Windows large pages need).
(Both were changed at once, so the isolated effect of each is not measured.) If the model still starts
slowly, the engine prints a hint under its `loaded ... GiB at ...` line naming this cause.
### Chat in the terminal (optional)
```
@@ -246,6 +322,27 @@ Terminal chat: `.venv/bin/python chat.py`.
---
## Sharing the GPU with other programs (optional)
By default the model stays loaded until you close Strata. On a PC that also games, renders or runs another model
server, three server options (all off by default; also as keys in `strata-<model>.json`) give the VRAM back:
| Option | Config key | What it does |
| --- | --- | --- |
| `--idle-unload 600` | `"idle_unload_s": 600` | unload the model after 600 s without requests; the next request loads it again |
| `--min-free-vram-mib 11000` | `"min_free_vram_mib": 11000` | load an unloaded model only when that much VRAM is free (it waits up to 15 s for memory being given back), else answer **503** "the GPU is in use by another program" instead of starting into what a game left (with several GPUs it checks the first one) |
| `--before-load "cmd"` | `"before_load": "cmd"` or `["cmd", "arg"]` | a command run before the model is loaded again, e.g. one that unloads another server's model |
`POST /unload` unloads it now (`409` while a request is running) and `POST /load` loads it ahead of a request;
`/health` says `"loaded"`, `/v1/models` lists it as `unloaded` (like llama.cpp's router), `/props` sets
`is_sleeping` and the Monitor shows the state. Unloading ends the engine process - and the image encoder, when images
are on; it is started again first, as at a start - so their VRAM and RAM go straight back. The model files stay in
the OS file cache, so loading again takes seconds while that RAM is not needed elsewhere. Measured on an RTX 5060 Ti
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).
---
## Using it
The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or edit the run script).
@@ -254,11 +351,14 @@ The server listens on `http://127.0.0.1:8080` (change with `--port` in setup, or
| --- | --- |
| OpenAI Chat Completions (stream and non-stream, tools) | `POST /v1/chat/completions` |
| Anthropic Messages (stream and non-stream, tools) | `POST /v1/messages` |
| Model list / health | `GET /v1/models`, `GET /health` |
| What the model is doing right now | `GET /status` |
| Model list / health | `GET /v1/models`, `GET /models`, `GET /health` |
| Model properties | `GET /props` (also accepts `?model=<loaded-model-id>`) |
| What the model is doing right now | `GET /status`, `GET /slots` (single slot, busy or idle) |
| Everything the Monitor tab shows (engine, live state, last requests, hardware) | `GET /metrics` |
| The MCP servers, their state and tools ([below](#tools-from-mcp-servers)) | `GET /mcp` |
`/models` and `/v1/models` list only the loaded model, with its context limit and input modalities. `/props` exposes the original chat template, context limit, configured generation defaults (shared settings take precedence), model path and engine version when available. Context means the full engine context, not the resident KV window. `n_predict: -1` means no fixed output cap. Unconfigured sampling fields are omitted. `autoload` has no effect; an unknown `model` returns 404. These metadata endpoints and `/slots` require the API key when one is configured. They do not load, unload or restart models.
```bash
curl http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d '{
"model": "strata", "messages": [{"role": "user", "content": "Write a haiku about GPUs."}], "max_tokens": 512 }'
@@ -318,9 +418,48 @@ prompt when that is 2,048 tokens or more (engine 0.1.20; PR #62 + #65), so that
system prompts and tool lists. Engine options: `--prompt-cache N` (0 = off), `--prompt-cache-every N`,
`--prompt-cache-root N` (0 = no system-prompt checkpoint), `--turn-token ID`.
**Current limits (v1):** one request at a time, and one conversation's history in the KV cache at a time (switching
between two chats re-reads the part where they diverge; the shared prefix, such as the system prompt, is reused); images
only when set up with them (below); no video. **Temperature / top_p / top_k / min_p /
**Multiple conversations (opt-in).** Add `--conversation-cache-mib 8192
--conversation-cache-slots 4` to the engine arguments to park up to four conversations
in a bounded 8 GiB host-RAM cache. This preserves controller/worker histories when
their requests alternate; it does not execute requests concurrently. No client session
ID is required: only exact token/image prefixes with matching steering mode are reused.
The default budget is 0 (disabled); `--prompt-cache 0` also disables parking.
The initial shared-core integration supports a single session GPU: combining
enabled parking with `--layer-split` is rejected before model loading. Ordinary
upstream layer-split checkpoints remain available with parking disabled. FP16,
INT8, Q4_0 and identity-layout K8V4 snapshots are supported; the K8V4 draft ring
remains INT8, as in upstream. Windows/HIP and multi-GPU runtime coverage must be
reported separately from Linux/CUDA evidence.
Snapshots contain running state, checkpoints, used K/V pages, and draft-layer K/V.
They add host RAM, not another model or VRAM allocation. The byte budget also counts
an incoming snapshot during a switch. After a restore, unchanged K/V pages can be
retained for the next parking operation; growth appends storage without copying
the existing pages. Rewinds refresh the affected pages, and running state and
checkpoints are captured again. Retained active K/V counts against the same byte
budget and is discarded before evicting parked entries under memory pressure.
If reserving space for growth would evict another conversation, parking uses a
full capture instead.
Oldest parked entries are evicted first.
Oversized snapshots or host allocation failures fall back to ordinary prompt processing.
`--conversation-cache-min-free-mib N` (default 2560) additionally requires that
physical-RAM headroom remain available: the engine checks before allocation and
again after capture. Unknown telemetry or insufficient RAM skips parking. Windows
uses `GlobalMemoryStatusEx`, Linux uses `MemAvailable`; these are host-level samples,
not a reservation or enforcement of container/job memory limits. An 8 GiB budget
is a cap, not a recommendation for every machine.
The shared snapshot core validates all layers and checkpoints before applying any
state. Invalid entries are discarded; transfer/synchronization failure is fatal
rather than permission to continue with partial state. Indexer spare keys and the
moving spare row are preserved, including checkpoint rewinds.
The engine log reports parking, restoration, bytes, evictions, individual snapshot
sizes and K/V bytes reused during capture. `STRATA_SNAPSHOT_FULL_CAPTURE=1` disables
retention for diagnostic comparisons. Snapshots are not
persisted across restarts.
**Current limits (v1):** one request at a time, and one conversation cached at a time (switching between two chats
re-reads the other one unless the opt-in cache above is enabled); images only when set up with them (below); no video. **Temperature / top_p / top_k / min_p /
seed** are honored per request (OpenAI and Anthropic fields); with the default adaptive expert tier a sampled result
is not reproducible run to run - for seed-reproducible output add `--adapt-every 100000` (static residency) to the
engine arguments. The run config's optional `sampling` block sets the defaults for requests that leave the fields out
@@ -375,6 +514,43 @@ it needs, prefer read-only tools, and don't add servers you don't trust. The too
page itself (a request with another site's Origin or without a JSON content type is refused); if Strata is reachable
from other devices, set an API key.
**Context extension past 262K (rope scaling, EXPERIMENTAL, off unless you pick it).** The model was trained on
262,144 positions (rotary base 1e7). Rope scaling rescales the rotation angles so that longer contexts stay usable,
with llama.cpp's types and flag names. `linear` is Position Interpolation: every angle is shrunk by the factor.
`yarn` keeps the high-frequency angles, interpolates the low-frequency ones, and adds the magnitude correction
that keeps the attention temperature where training put it. **Without the flags nothing changes:** an unscaled
run computes exactly what it did before the feature existed, bit for bit. Scaled contexts need proportionally
more VRAM/RAM for the KV cache and the rope tables (~13 KB and ~0.26 KB per token).
What was measured (contributors' runs, RTX 5080 + IQ3_S, native path, in PR #84): per-position perplexity on the
same tokens, with only the scaling flag changed. At 293K tokens (1.12x the trained length), `yarn` with factor 2
lowered the NLL by 0.18 nats against both `none` and `linear` 2 (2.04 vs 2.22 / 2.22). That is 3-4x the path noise
measured at the same length. `linear` 2 was indistinguishable from `none`. At 2.7K and 32K no arm separated from the
noise. Needle tests do not tell the arms apart: the unscaled model also finds a needle at 413K. Long real-document
Q&A worked with `yarn` 2 at 421K and `yarn` 4 at 714K (8/8 each), and a 1M-token `yarn` 4 run read end to end.
Taken together, use **yarn**. It is still experimental: the numbers come from one machine and one quant.
- setup: `START-HERE.bat --setup --context 393216` asks nothing extra - it picks the method (yarn; one
question when run interactively) and derives the factor from the final context for you (final context /
262,144, at least 1: 1.5 at 393K, 2 at 512K, 1 inside the trained range; `--rope-scaling`/`--rope-scale`
override; an explicit `--rope-scale` is kept as given even when it is too small for the context actually
served, so check it if you set one). An explicit `--rope-scaling none` for a context past
262,144 is refused: the setup will not configure a run with the stock angles past the trained range. If
the RAM check reduces a chosen 384K/512K back inside the trained range, an omitted method adds no
scaling, and an explicitly chosen one stays at factor 1 - the trained angles, no expansion (not a
switch for rope as a whole: explicitly supplied rope settings keep their behavior).
- engine: `--rope-scaling none|linear|yarn`, `--rope-scale F`, and the raw ggml knobs `--rope-freq-base`,
`--rope-freq-scale`, `--yarn-orig-ctx` (default 262,144), `--yarn-ext-factor`, `--yarn-attn-factor`,
`--yarn-beta-fast` (32), `--yarn-beta-slow` (1). The model file's `rope.scaling.*` keys, when a
fine-tune ships them, are the defaults the flags override.
The scaling is fixed for the whole run - the engine stores keys in its cache after rotating them, so one
cache must never mix two scalings, and there is no per-request form. Within the trained 262,144 a scaled
run is a slightly different model: the rescaled angles, and `yarn`'s magnitude correction, apply at every
position, not only past the trained end. That is why the setup turns scaling on only for a context past
262,144. Pictures read the same scaled table (their (t, h, w) positions feed it). That should work, but it is
unmeasured: all the runs above are text.
---
## Images (vision)
@@ -504,6 +680,7 @@ the document, +0.4% on the chat. Details: `bench/results/2026-09-27-esp/`.
| `the engine stopped unexpectedly (exit code ...)` | The engine process ended mid-answer - usually out of RAM (Linux ends the biggest program: `sudo dmesg \| grep -i -E 'killed process\|out of memory'`). The next request starts it again by itself. If it repeats: close other programs or pick a smaller size. The server also warns at start when the model's experts leave less than ~6 GB of RAM for everything else. |
| Slow output, disk light busy | Not enough free RAM: close other programs, or choose Q2_0 / IQ2_XS. |
| `prompt ... exceeds the context` | The request is longer than the context you chose: run setup again with a bigger `--context`. |
| `the setup refuses --rope-scaling none for a past-trained context` | A context past the trained 262,144 needs the rotary angles rescaled (experimental rope scaling), and the setup will not configure one with the stock angles there. Let it pick (`START-HERE.bat --setup --context 393216` adds yarn and a covering factor), or pass `--rope-scaling linear` or `yarn` yourself. |
| Slower than the tables | The monitor plugged into the GPU and other GPU programs take VRAM from the expert cache; RAM running below its rated speed (enable EXPO/XMP in the BIOS) slows the CPU half. |
| `this server was started without the vision encoder` | The model was set up for text only: run setup again with `--vision gpu`. |
| A picture is refused or `cannot read the image` | The file is not a picture Pillow can open (JPEG, PNG, WebP, GIF, BMP, TIFF, AVIF work). |
+6 -5
View File
@@ -18,7 +18,7 @@ Strata can use it:
```
Your NVIDIA GPUs:
GPU 0: NVIDIA GeForce RTX 5080, 16 GB VRAM - can be used
GPU 1: NVIDIA GeForce RTX 2080 Ti, 22 GB VRAM - not supported - older than the RTX 30 series (compute capability 7.5; Strata needs 8.0 or newer)
GPU 1: NVIDIA GeForce GTX 1080 Ti, 11 GB VRAM - not supported - older than the RTX 20 series (compute capability 6.1; Strata needs 7.5 or newer)
GPU 2: NVIDIA GeForce RTX 3090, 24 GB VRAM - can be used
...
1) GPU 0 (NVIDIA GeForce RTX 5080, 16 GB) + GPU 2 (NVIDIA GeForce RTX 3090, 24 GB) together (recommended)
@@ -41,10 +41,11 @@ 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 30 series (compute capability below 8.0: RTX 20, GTX 16/10);
- 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);
- AMD and Intel GPUs, and a mix of NVIDIA with them.
- AMD and Intel GPUs, and a mix of NVIDIA with them. (Two AMD RDNA4 cards run the split from a hand-written
config; see [AMD_HIP.md](AMD_HIP.md#rdna4-gfx1201).)
Or edit an existing config (`strata-*.json`), then restart:
@@ -96,8 +97,8 @@ into the card that owns the layer.
capped to leave room for them.
- On Windows only 8 GiB of the expert arena is pinned (more, mapped into two GPU contexts, leaves WDDM refusing
allocations); the PCIe share covers those layers.
- Every card needs compute capability 8.0 (RTX 30 or newer). A Turing card (RTX 20, sm_75) builds only with the
experimental `-DSTRATA_EXPERIMENTAL_SM75=ON`.
- Every card needs compute capability 7.5 (RTX 20 or newer). The pre-sm_80 QSA scorer path is fp32 FMAs, so a
Turing card runs the same kernels instead of the tensor-core prompt attention.
## Measured
+299
View File
@@ -0,0 +1,299 @@
{
"source_commit": "9568e78da663e6b224ab96d57baf450717b36e73",
"date": "2026-09-29",
"candidate_binary_sha256": "705f5925a788bc227bad72abfb5466032e0879d752fea9afc3eb32051b35182b",
"note": "Fresh same-session control and candidate runs. Throughput cap 128; not a coding-quality benchmark.",
"control": [
{
"kind": "warmup",
"trial": 0,
"wall_s": 0.45770244899904355,
"metrics": {
"prompt_tokens": 15.0,
"reused": 0.0,
"fresh": 15.0,
"prefill_ms": 335.0,
"prefill_tps": 44.8,
"generated": 2.0,
"decode_ms": 115.0,
"decode_tps": 17.5
},
"finish_reason": "stop"
},
{
"kind": "cold",
"trial": 1,
"wall_s": 20.068275763012934,
"metrics": {
"prompt_tokens": 4210.0,
"reused": 0.0,
"fresh": 4210.0,
"prefill_ms": 17538.0,
"prefill_tps": 240.0,
"generated": 128.0,
"decode_ms": 2518.0,
"decode_tps": 50.8
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 1,
"wall_s": 4.412000925018219,
"metrics": {
"prompt_tokens": 4451.0,
"reused": 4337.0,
"fresh": 114.0,
"prefill_ms": 1943.0,
"prefill_tps": 58.7,
"generated": 128.0,
"decode_ms": 2451.0,
"decode_tps": 52.2
},
"finish_reason": "length"
},
{
"kind": "cold",
"trial": 2,
"wall_s": 20.731399575975956,
"metrics": {
"prompt_tokens": 8830.0,
"reused": 0.0,
"fresh": 8830.0,
"prefill_ms": 18213.0,
"prefill_tps": 484.8,
"generated": 128.0,
"decode_ms": 2505.0,
"decode_tps": 51.1
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 2,
"wall_s": 5.0592734439997,
"metrics": {
"prompt_tokens": 9071.0,
"reused": 8823.0,
"fresh": 248.0,
"prefill_ms": 2601.0,
"prefill_tps": 95.4,
"generated": 128.0,
"decode_ms": 2441.0,
"decode_tps": 52.4
},
"finish_reason": "length"
},
{
"kind": "cold",
"trial": 3,
"wall_s": 11.695648370019626,
"metrics": {
"prompt_tokens": 4210.0,
"reused": 0.0,
"fresh": 4210.0,
"prefill_ms": 9209.0,
"prefill_tps": 457.2,
"generated": 128.0,
"decode_ms": 2477.0,
"decode_tps": 51.7
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 3,
"wall_s": 4.093982424004935,
"metrics": {
"prompt_tokens": 4451.0,
"reused": 4337.0,
"fresh": 114.0,
"prefill_ms": 1896.0,
"prefill_tps": 60.1,
"generated": 128.0,
"decode_ms": 2188.0,
"decode_tps": 58.5
},
"finish_reason": "length"
},
{
"kind": "cold",
"trial": 4,
"wall_s": 20.84539221401792,
"metrics": {
"prompt_tokens": 8830.0,
"reused": 0.0,
"fresh": 8830.0,
"prefill_ms": 18437.0,
"prefill_tps": 478.9,
"generated": 128.0,
"decode_ms": 2392.0,
"decode_tps": 53.5
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 4,
"wall_s": 5.357054464984685,
"metrics": {
"prompt_tokens": 9071.0,
"reused": 8823.0,
"fresh": 248.0,
"prefill_ms": 2658.0,
"prefill_tps": 93.3,
"generated": 128.0,
"decode_ms": 2682.0,
"decode_tps": 47.7
},
"finish_reason": "length"
}
],
"optimized": [
{
"kind": "warmup",
"trial": 0,
"wall_s": 0.46265592801501043,
"metrics": {
"prompt_tokens": 15.0,
"reused": 0.0,
"fresh": 15.0,
"prefill_ms": 335.0,
"prefill_tps": 44.8,
"generated": 2.0,
"decode_ms": 121.0,
"decode_tps": 16.5
},
"finish_reason": "stop"
},
{
"kind": "cold",
"trial": 1,
"wall_s": 11.728894605010282,
"metrics": {
"prompt_tokens": 4210.0,
"reused": 0.0,
"fresh": 4210.0,
"prefill_ms": 9408.0,
"prefill_tps": 447.5,
"generated": 128.0,
"decode_ms": 2308.0,
"decode_tps": 55.5
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 1,
"wall_s": 2.9531024169991724,
"metrics": {
"prompt_tokens": 4451.0,
"reused": 4337.0,
"fresh": 114.0,
"prefill_ms": 740.0,
"prefill_tps": 154.0,
"generated": 128.0,
"decode_ms": 2193.0,
"decode_tps": 58.4
},
"finish_reason": "length"
},
{
"kind": "cold",
"trial": 2,
"wall_s": 12.42044531099964,
"metrics": {
"prompt_tokens": 8830.0,
"reused": 0.0,
"fresh": 8830.0,
"prefill_ms": 10107.0,
"prefill_tps": 873.7,
"generated": 128.0,
"decode_ms": 2298.0,
"decode_tps": 55.7
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 2,
"wall_s": 3.2599652509961743,
"metrics": {
"prompt_tokens": 9071.0,
"reused": 8823.0,
"fresh": 248.0,
"prefill_ms": 1070.0,
"prefill_tps": 231.7,
"generated": 128.0,
"decode_ms": 2173.0,
"decode_tps": 58.9
},
"finish_reason": "length"
},
{
"kind": "cold",
"trial": 3,
"wall_s": 6.937167472002329,
"metrics": {
"prompt_tokens": 4210.0,
"reused": 0.0,
"fresh": 4210.0,
"prefill_ms": 4672.0,
"prefill_tps": 901.1,
"generated": 128.0,
"decode_ms": 2256.0,
"decode_tps": 56.7
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 3,
"wall_s": 3.1877827199932653,
"metrics": {
"prompt_tokens": 4451.0,
"reused": 4337.0,
"fresh": 114.0,
"prefill_ms": 827.0,
"prefill_tps": 137.8,
"generated": 128.0,
"decode_ms": 2350.0,
"decode_tps": 54.5
},
"finish_reason": "length"
},
{
"kind": "cold",
"trial": 4,
"wall_s": 11.314008287008619,
"metrics": {
"prompt_tokens": 8830.0,
"reused": 0.0,
"fresh": 8830.0,
"prefill_ms": 9136.0,
"prefill_tps": 966.5,
"generated": 128.0,
"decode_ms": 2161.0,
"decode_tps": 59.2
},
"finish_reason": "length"
},
{
"kind": "followup",
"trial": 4,
"wall_s": 3.0476601750124246,
"metrics": {
"prompt_tokens": 9071.0,
"reused": 8957.0,
"fresh": 114.0,
"prefill_ms": 734.0,
"prefill_tps": 155.3,
"generated": 128.0,
"decode_ms": 2296.0,
"decode_tps": 55.7
},
"finish_reason": "length"
}
],
"control_binary_sha256": "4522d4937ca4a41ca294d31b60df8c2bc4df5b7a6caf65eaad10c036e08491f7"
}
+9
View File
@@ -26,11 +26,15 @@
#include <string>
#include <vector>
#include <map>
#include <set>
#include <stdexcept>
#include <algorithm>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#ifndef NOMINMAX
#define NOMINMAX // windows.h's min/max macros would break std::min/std::max in every file that includes this one
#endif
#include <windows.h>
#else
#include <fcntl.h>
@@ -418,9 +422,14 @@ private:
meta_.emplace(std::move(key), read_value(c, t));
}
tensors_.reserve((size_t)n_tensors);
// GGUF has no index to arbitrate between two tensors of one name: find() is first-match, so a
// duplicate would silently win by position. Refuse the file at open instead, naming both.
std::set<std::string> names;
for (uint64_t i = 0; i < n_tensors; ++i) {
TensorInfo t;
t.name = c.str();
if (!names.insert(t.name).second)
throw std::runtime_error("GGUF: duplicate tensor name '" + t.name + "' in " + path_);
const uint32_t nd = c.read<uint32_t>();
if (nd == 0 || nd > 4) throw std::runtime_error("GGUF: bad n_dims for " + t.name);
t.shape.resize(nd);
+154
View File
@@ -0,0 +1,154 @@
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <initializer_list>
#include <utility>
#include <vector>
namespace strata::core {
// Snapshot-owned host storage. Appending never relocates existing payloads;
// only the small segment directory can move. No sharing or copy-on-write is
// needed because a parked image and its retained active buffers have one owner.
class ConversationBuffer {
public:
static constexpr size_t segment_bytes = 16 * 1024 * 1024;
ConversationBuffer() = default;
ConversationBuffer(std::initializer_list<uint8_t> values) {
resize(values.size());
visit(0, size(), [&](uint8_t* p, size_t n, size_t at) {
std::memcpy(p, values.begin() + at, n);
return true;
});
}
ConversationBuffer(const ConversationBuffer&) = default;
ConversationBuffer& operator=(const ConversationBuffer&) = default;
ConversationBuffer(ConversationBuffer&& other) noexcept
: segments_(std::move(other.segments_)), size_(std::exchange(other.size_, 0)) {}
ConversationBuffer& operator=(ConversationBuffer&& other) noexcept {
if (this == &other) return *this;
segments_ = std::move(other.segments_);
size_ = std::exchange(other.size_, 0);
return *this;
}
size_t size() const { return size_; }
bool empty() const { return size_ == 0; }
size_t bytes() const {
size_t n = segments_.capacity() * sizeof(Segment);
for (const auto& segment : segments_) n += segment.capacity();
return n;
}
// Includes the old directory while reserve allocates its replacement.
// Saturation makes an overflowing estimate fail admission before resize.
size_t allocation_peak(size_t n) const {
size_t total = bytes();
if (n <= size_) return total;
const size_t spare = segments_.empty() ? 0 : segments_.back().capacity() - segments_.back().size();
const size_t extra = n - size_ - std::min(n - size_, spare);
const size_t full = extra / segment_bytes, tail = extra % segment_bytes;
if (!add(total, full * segment_bytes)) return SIZE_MAX;
if (tail && !add(total, segment_capacity(tail, full ? segment_bytes :
segments_.empty() ? 0 : segments_.back().capacity(), size_ != 0))) return SIZE_MAX;
size_t count = segments_.size();
if (!add(count, full + (tail != 0))) return SIZE_MAX;
if (count > segments_.capacity() &&
(count > SIZE_MAX / sizeof(Segment) || !add(total, count * sizeof(Segment)))) return SIZE_MAX;
return total;
}
void resize(size_t n, uint8_t value = 0) {
if (n > size_) {
const bool growing = size_ != 0;
const size_t spare = segments_.empty() ? 0 : segments_.back().capacity() - segments_.back().size();
const size_t extend = std::min(n - size_, spare);
const size_t extra = n - size_ - extend;
segments_.reserve(segments_.size() + extra / segment_bytes + (extra % segment_bytes != 0));
if (extend) {
segments_.back().resize(segments_.back().size() + extend, value);
size_ += extend;
}
while (size_ < n) {
const size_t count = std::min(segment_bytes, n - size_);
const size_t previous = segments_.empty() ? 0 : segments_.back().capacity();
Segment segment;
segment.reserve(segment_capacity(count, previous, growing));
segment.resize(count, value);
segments_.push_back(std::move(segment));
size_ += count;
}
} else {
while (!segments_.empty() && size_ - segments_.back().size() >= n) {
size_ -= segments_.back().size();
segments_.pop_back();
}
if (size_ > n) segments_.back().resize(segments_.back().size() - (size_ - n));
size_ = n;
}
}
void pop_back() { resize(size_ - 1); }
// fn(pointer, count, absolute_offset); false aborts a transfer immediately.
template<class Fn> bool visit(size_t offset, size_t count, Fn fn) {
return visit_impl(*this, offset, count, fn);
}
template<class Fn> bool visit(size_t offset, size_t count, Fn fn) const {
return visit_impl(*this, offset, count, fn);
}
bool read(void* target, size_t offset, size_t count) const {
return visit(offset, count, [&](const uint8_t* p, size_t n, size_t at) {
std::memcpy(static_cast<uint8_t*>(target) + at - offset, p, n);
return true;
});
}
bool operator==(const ConversationBuffer& other) const {
if (size_ != other.size_) return false;
size_t a = 0, b = 0, x = 0, y = 0;
while (a < segments_.size() && b < other.segments_.size()) {
const auto& left = segments_[a];
const auto& right = other.segments_[b];
const size_t n = std::min(left.size() - x, right.size() - y);
if (std::memcmp(left.data() + x, right.data() + y, n)) return false;
x += n; y += n;
if (x == left.size()) { ++a; x = 0; }
if (y == right.size()) { ++b; y = 0; }
}
return true;
}
private:
using Segment = std::vector<uint8_t>;
// A fresh capture allocates exactly its payload. Later appends reserve
// geometrically growing segments, bounded by 16 MiB. Small chat turns then
// extend the final allocation instead of adding one transfer per turn.
static size_t segment_capacity(size_t count, size_t previous, bool growing) {
if (!growing) return count;
return std::max(count, std::min(segment_bytes, std::max<size_t>(65536, std::min(previous, segment_bytes/2)*2)));
}
static bool add(size_t& n, size_t extra) {
if (extra > SIZE_MAX - n) return false;
n += extra;
return true;
}
template<class Self, class Fn> static bool visit_impl(Self& self, size_t offset, size_t count, Fn fn) {
if (offset > self.size_ || count > self.size_ - offset) return false;
size_t begin = 0;
for (auto& segment : self.segments_) {
if (!count) break;
const size_t end = begin + segment.size();
if (offset < end) {
const size_t n = std::min(count, end - offset);
if (!fn(segment.data() + offset - begin, n, offset)) return false;
offset += n; count -= n;
}
begin = end;
}
return count == 0;
}
std::vector<Segment> segments_;
size_t size_ = 0;
};
} // namespace strata::core
+181
View File
@@ -0,0 +1,181 @@
// CPU-only ownership and matching policy for --serve's parked conversations.
// Token equality, image identity, and steering mode are all required for reuse.
#pragma once
#include "strata/core/conversation_buffer.hpp"
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <deque>
#include <utility>
#include <vector>
namespace strata::core {
struct ConversationImageKey {
int64_t start = 0;
uint64_t hash = 0;
bool operator==(const ConversationImageKey&) const = default;
};
struct ConversationCheckpoint {
std::vector<int32_t> ids;
std::vector<ConversationImageKey> imgs;
std::vector<uint8_t> gdn, ple, tails, dead, block_pos;
uint64_t used = 0; // upstream root-pinned/LRU checkpoint retention
// Ordinary layer-split checkpoints retain each device's running state.
// Whole-session parking is currently single-GPU and rejects these parts.
std::vector<ConversationCheckpoint> stage_parts;
size_t bytes() const {
size_t n = ids.capacity() * sizeof(int32_t) + imgs.capacity() * sizeof(ConversationImageKey) +
gdn.capacity() + ple.capacity() + tails.capacity() + dead.capacity() + block_pos.capacity() +
stage_parts.capacity() * sizeof(ConversationCheckpoint);
for (const auto& part : stage_parts) n += part.bytes();
return n;
}
};
// Identity-layout K/V pages and completed indexer rows. For streamed layers the
// source is the authoritative host pool, NOT the replaceable VRAM slots.
struct ConversationKv {
int format = 0;
int64_t cells = 0, heads = 0, head_dim = 0, page_size = 0, pooled_rows = 0, idx_dim = 0;
ConversationBuffer k, v, k_scale, v_scale, pooled;
size_t bytes() const {
return k.bytes() + v.bytes() + k_scale.bytes() + v_scale.bytes() + pooled.bytes();
}
};
struct ConversationKvReuse {
std::vector<ConversationKv> kv;
// Original image extent for validation, and the earliest subsequent rewrite.
int64_t captured_tokens = 0, unchanged_tokens = 0;
size_t bytes() const {
size_t n = kv.capacity() * sizeof(ConversationKv);
for (const auto& layer : kv) n += layer.bytes();
return n;
}
};
struct SavedConversation {
// Runtime compatibility only; NOT a model/weights identity or disk schema.
std::array<int64_t, 18> geometry{};
// The session's layer carve the image was captured from ([0, n_layers) on one GPU); restore requires the same.
int64_t layer_lo = 0, layer_hi = 0;
ConversationCheckpoint live;
std::vector<ConversationCheckpoint> checkpoints;
std::vector<ConversationKv> kv; // main layers followed by the draft layer
bool cvec = true;
size_t bytes() const {
size_t n = live.bytes() + checkpoints.capacity() * sizeof(ConversationCheckpoint) +
kv.capacity() * sizeof(ConversationKv);
for (const auto& c : checkpoints) n += c.bytes();
for (const auto& k : kv) n += k.bytes();
return n;
}
};
template<class Token>
int64_t conversation_prefix(const ConversationCheckpoint& c, const std::vector<Token>& prompt,
const std::vector<ConversationImageKey>& images) {
const size_t n = c.ids.size();
// The last prompt token always starts the next verify window.
if (n == 0 || n >= prompt.size() || !std::equal(c.ids.begin(), c.ids.end(), prompt.begin())) return 0;
size_t j = 0;
for (const auto& image : images) {
if (image.start >= (int64_t) n) continue;
if (j == c.imgs.size() || !(c.imgs[j++] == image)) return 0;
}
if (j != c.imgs.size()) return 0;
return (int64_t) n;
}
class ConversationCache {
public:
struct Match {
size_t index = 0;
int64_t tokens = 0;
bool live = false;
};
ConversationCache(size_t budget, size_t slots) : budget_(budget), slots_(slots) {}
bool enabled() const { return budget_ != 0 && slots_ != 0; }
size_t bytes() const { return bytes_ + reuse_.bytes(); }
size_t size() const { return entries_.size(); }
size_t evictions() const { return evictions_; }
// Retain only the restored K/V buffers, not duplicate running checkpoints.
// This optimization never evicts a parked conversation to make itself fit.
void retain(std::vector<ConversationKv>&& kv, int64_t tokens) {
reuse_ = {};
ConversationKvReuse candidate{std::move(kv), tokens, tokens};
if (enabled() && candidate.bytes() <= budget_ - bytes_) reuse_ = std::move(candidate);
}
void limit_reuse(int64_t first_dirty) {
reuse_.unchanged_tokens = std::min(reuse_.unchanged_tokens, first_dirty);
if (reuse_.unchanged_tokens <= 0) reuse_ = {};
}
ConversationKvReuse take_reuse() { return std::exchange(reuse_, {}); }
size_t retained_bytes() const { return reuse_.bytes(); }
bool can_fit(size_t incoming, size_t held = 0) const {
return enabled() && held <= budget_ && incoming <= budget_ - held &&
entries_.size() < slots_ && bytes() <= budget_ - held - incoming;
}
template<class Token>
Match best(const std::vector<Token>& prompt, const std::vector<ConversationImageKey>& images, bool cvec) const {
Match best;
// Ties prefer the most recently parked branch. The caller prefers its
// already-active state when that offers the same prefix length.
for (size_t i = entries_.size(); i-- > 0;) {
const auto& e = entries_[i];
if (e.cvec != cvec) continue;
auto consider = [&](const ConversationCheckpoint& c, bool live) {
const int64_t n = conversation_prefix(c, prompt, images);
if (n > best.tokens) best = {i, n, live};
};
consider(e.live, true);
for (const auto& c : e.checkpoints) consider(c, false);
}
return best;
}
SavedConversation take(size_t index) {
SavedConversation out = std::move(entries_.at(index));
bytes_ -= out.bytes();
entries_.erase(entries_.begin() + (std::ptrdiff_t) index);
return out;
}
// Reserve before allocating a snapshot. held is an incoming image removed
// with take() but still alive during the exchange; count it against RAM too.
bool make_room(size_t incoming, size_t held = 0) {
if (!enabled() || held > budget_ || incoming > budget_ - held) return false;
if (bytes() > budget_ - held - incoming) reuse_ = {};
while (!entries_.empty() && (entries_.size() >= slots_ || bytes_ > budget_ - held - incoming)) {
bytes_ -= entries_.front().bytes();
entries_.pop_front();
++evictions_;
}
return true;
}
bool put(SavedConversation&& image, size_t held = 0) {
const size_t n = image.bytes();
if (!make_room(n, held)) return false;
entries_.push_back(std::move(image));
bytes_ += n;
return true;
}
private:
size_t budget_ = 0, slots_ = 0, bytes_ = 0, evictions_ = 0;
std::deque<SavedConversation> entries_; // least recently active first
ConversationKvReuse reuse_;
};
} // namespace strata::core
@@ -0,0 +1,19 @@
#pragma once
#include <cstdint>
#include <istream>
#include <optional>
namespace strata::core {
// Host physical memory, not swap/commit or a container/job memory reservation.
// Unknown telemetry is deliberately distinct from a measured zero.
std::optional<uint64_t> conversation_available_memory();
std::optional<uint64_t> conversation_mem_available(std::istream& meminfo);
inline bool conversation_memory_admit(std::optional<uint64_t> available,
uint64_t allocation, uint64_t floor) {
return available && *available >= floor && allocation <= *available - floor;
}
} // namespace strata::core
@@ -0,0 +1,79 @@
#pragma once
#include "strata/core/conversation_cache.hpp"
#include "strata/core/layer.hpp"
#include "strata/core/session.hpp"
#include <string>
namespace strata::core {
// Caller synchronizes the device before saving, and after restoring all layers.
// include_index is false for the draft layer (its attention has no indexer).
size_t conversation_kv_bytes(const QsaState& state, const ModelGeometry& g, int64_t upto, bool include_index);
// A nonzero unchanged_tokens is valid only for storage retained from an image
// actually restored into this session, bounded by every subsequent rewrite.
// Equal token IDs alone do not establish that its K/V bytes are unchanged.
bool conversation_kv_save(ConversationKv& image, const QsaState& state, const ModelGeometry& g,
int64_t upto, bool include_index, std::string& error,
int64_t unchanged_tokens = 0, size_t* reused_bytes = nullptr);
bool conversation_kv_capture_bytes(const ConversationKv& image, const QsaState& state, const ModelGeometry& g,
int64_t upto, bool include_index, size_t& bytes, std::string& error);
// No CUDA calls or destination writes. Used for whole-session prevalidation.
bool conversation_kv_validate(const ConversationKv& image, const QsaState& state, const ModelGeometry& g,
int64_t upto, bool include_index, std::string& error);
bool conversation_kv_restore(const ConversationKv& image, const QsaState& state, const ModelGeometry& g,
int64_t upto, bool include_index, std::string& error);
// Diagnostic read-back after a synchronized restore. Uses 64 KiB of stack
// workspace, compares authoritative bytes and resident draft-ring pages, and
// fingerprints the authoritative payload only. Never changes model state.
bool conversation_kv_verify(const ConversationKv& image, const QsaState& state, const ModelGeometry& g,
int64_t upto, bool include_index, uint64_t& fingerprint, std::string& error);
struct ConversationStateSizes {
size_t gdn = 0, ple = 0, tail = 0, dead = 0, block_pos = 0;
};
/// Whole-model sizes: `gdn` covers every GDN layer, the indexer sizes are per QSA layer.
bool conversation_state_sizes(const ModelGeometry& g, ConversationStateSizes& sizes, std::string& error);
/// The same for one session's layer carve (#216): `gdn` covers its `gdn_alloc` rows; the per-QSA-layer sizes
/// apply to each owned state [qsa_ord0, qsa_ord0 + qsa_alloc). Rejects an inconsistent carve. Checkpoints,
/// snapshots and their validation all use this: a session saves and restores only the state it owns.
bool conversation_session_sizes(const ModelGeometry& g, const SessionState& session, ConversationStateSizes& sizes,
std::string& error);
bool conversation_checkpoint_validate(const ConversationCheckpoint& checkpoint, const SessionState& session,
const ModelGeometry& g, std::string& error);
bool conversation_checkpoint_save(ConversationCheckpoint& checkpoint, const SessionState& session,
const ModelGeometry& g, std::string& error);
bool conversation_checkpoint_restore(const ConversationCheckpoint& checkpoint, SessionState& session,
const ModelGeometry& g, std::string& error);
struct ConversationView {
const std::vector<int32_t>& ids;
const std::vector<ConversationImageKey>& images;
const std::vector<ConversationCheckpoint>& checkpoints;
bool cvec;
};
bool conversation_snapshot_bytes(const ConversationView& view, const SessionState& session,
const ModelGeometry& g, const QsaState& draft, size_t& bytes, std::string& error);
bool conversation_snapshot_capture_bytes(const ConversationKvReuse& reuse, const ConversationView& view,
const SessionState& session, const ModelGeometry& g,
const QsaState& draft, size_t& bytes, std::string& error);
// The capture estimate includes retained capacity and transient segment directories;
// only estimate - reuse.bytes() requires additional physical RAM. Capture consumes
// the uniquely owned reusable buffers, including on failure.
// Caller admits the estimate before invoking capture. Allocation failures propagate
// to the RAM policy; the active session is never modified by capture.
bool conversation_snapshot_save(SavedConversation& image, const ConversationView& view,
const SessionState& session, const ModelGeometry& g,
const QsaState& draft, std::string& error,
ConversationKvReuse reuse = {}, size_t* reused_bytes = nullptr);
bool conversation_snapshot_validate(const SavedConversation& image, const SessionState& session,
const ModelGeometry& g, const QsaState& draft, std::string& error);
enum class ConversationRestore { restored, invalid, transfer_failed };
// Invalid images are rejected before any CUDA call/write. Transfer failure may
// leave partial state: caller MUST NOT continue inference from that session.
ConversationRestore conversation_snapshot_restore(const SavedConversation& image, SessionState& session,
const ModelGeometry& g, const QsaState& draft,
std::string& error);
} // namespace strata::core
+82
View File
@@ -0,0 +1,82 @@
// include/strata/core/coupled_draft.hpp - COUPLED DRAFT SAMPLING (STRATA_SPEC_COUPLED=1, off by default).
//
// Under sampling the verify window samples row t of a window at position pos0 as Philox(seed, pos0 + t) over the
// sampler chain (penalties -> top_k -> top_p -> min_p -> temperature -> inverse-CDF pick; verify.cpp run(),
// sampler.cu), and a draft is kept only when it EQUALS that row's pick. The output is therefore a function of the
// seed alone, whatever the drafts are: drafts only decide how many tokens a window yields.
//
// The MTP draft layer used to propose its ARGMAX. In coupled mode it SAMPLES instead, with the target's own chain
// (the request's top_k / top_p / min_p / temperature and penalties over the same history) and the SAME uniform the
// target will draw for the row that verifies the draft. When the draft's distribution is close to the target's,
// the two inverse-CDF picks from one uniform agree far more often than an argmax agrees with a sample; the target's
// pick - and so the text - does not change, because verification is still exact-match against the target's sample.
//
// THE COUNTER ARITHMETIC (all positions are sequence indices; a window's row t holds the token at pos0 + t and its
// pick is the token at pos0 + t + 1, drawn with counter pos0 + t):
// * MTP cell c pairs the main model's residual at position c with the token at c + 1 and predicts the token at
// c + 2 (mtp.hpp).
// * After a window at p with a drafts accepted, the next window starts at p' = p + a + 1 (its row 0 is the token
// the window picked at row a). Chain step j (j = 0 ..) runs at cell p + a + j and predicts the token at
// p + a + j + 2 = p' + j + 1: row j + 1 of the next window, VERIFIED BY ROW j's pick, counter p' + j.
// * So a draft made at cell c is verified with counter c + 1 - whatever the window size T, the accepted count, a
// suffix-drafter window in between (its drafts are then simply not used), or `draft_first` (cell p - 1, a = 0 for
// a window at p). The kernel reads c from the step record the host already stages for the cell (device memory,
// so the captured graphs replay with each round's own counter), and the seed and chain from a device copy of the
// request's SamplerParams, refreshed at the start of every round graph.
//
// THE PENALTY HISTORY. The verify row j of the next window counts penalties over penalty_rows(consumed', window')
// row j: the last h of (consumed', x', drafts 0 .. j-1), where consumed' is what the session holds after the commit
// and x' the window's pick at row a. The drafter keeps a device RING of kCoupledHistCap + max_t slots: the host
// stages the base (the last h of consumed' + x', -1 padded in front) in [cap - h, cap) before the round, and draft j
// writes its token to cap + j - so draft j's window is [cap + j - h, cap + j), exactly the row the target will use.
#pragma once
#include "strata/kernels/sampler.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#if defined(__CUDACC__) || defined(__HIPCC__)
#define STRATA_COUPLED_HD __host__ __device__
#else
#define STRATA_COUPLED_HD
#endif
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 = [] {
const char* e = std::getenv("STRATA_SPEC_COUPLED");
return e != nullptr && *e != '\0' && std::strcmp(e, "0") != 0;
}();
return 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; }
/// The Philox counter the verify window will draw for the row that checks the draft made at MTP cell `cell`.
STRATA_COUPLED_HD inline uint64_t coupled_draft_counter(int64_t cell) { return (uint64_t) (cell + 1); }
/// The penalty window the draft layer and the target use: min(penalty_last_n, cap), 0 when penalties are off.
STRATA_COUPLED_HD inline int coupled_hist_len(int penalty_last_n, int cap) {
return penalty_last_n <= 0 ? 0 : (penalty_last_n < cap ? penalty_last_n : cap);
}
/// Where draft j's penalty window starts in the ring (base in [cap - h, cap), draft i at cap + i).
STRATA_COUPLED_HD inline int coupled_hist_start(int cap, int j, int h) { return cap + j - h; }
/// The ring's base on the host: the last `h` tokens of (tail[0..n_tail), next), -1 padded in front, into
/// out[0..h) - row 0 of `penalty_rows` for the one-token window [next].
inline void coupled_hist_base(const int32_t* tail, int64_t n_tail, int32_t next, int h, int32_t* out) {
if (h <= 0) return;
strata::kernels::penalty_rows(tail, n_tail, &next, 1, h, out);
}
} // namespace strata::core
+10
View File
@@ -26,8 +26,18 @@ struct DeviceInfo {
uint64_t free_bytes = 0;
int driver_version = 0, runtime_version = 0;
int multi_processor_count = 0;
std::string arch; // HIP: gcnArchName without its feature suffix ("gfx1201"); empty on CUDA
};
// HIP builds: whether GPU `ordinal` can run this binary - its architecture must be one the binary was COMPILED
// for (STRATA_HIP_ARCHS, set by cmake/hip_backend.cmake) and it must run wave32. "" when it can (or when there
// is no such device: the caller's own device errors apply), else the reason in a sentence. A binary carried to
// another card would otherwise fail later with "invalid device function". CUDA builds: always "".
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();
// 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).
+6
View File
@@ -26,6 +26,7 @@
// kernel. This is that first step, and the step it unblocks is the one that can be measured.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
@@ -140,6 +141,11 @@ public:
int64_t fills() const { return fills_; }
private:
#if defined(STRATA_USE_HIP)
bool ensure_blocking_staging(std::size_t bytes, std::string& err);
uint8_t* blocking_staging_ = nullptr;
std::size_t blocking_staging_bytes_ = 0;
#endif
uint8_t* base_ = nullptr;
std::vector<int32_t> residency_; ///< [n_layers * n_expert] -> slot or kNotResident
int64_t slots_ = 0;
+136 -3
View File
@@ -25,7 +25,10 @@
#include "strata/core/hit_hook.hpp"
#include "strata/kernels/cpu/pool.hpp"
#include <atomic>
#include <cstdint>
#include <cstddef>
#include <utility>
#include <string>
#include <vector>
@@ -33,6 +36,53 @@ namespace strata::core {
class RemoteExperts;
namespace detail {
/// Sentinel used by the pure complement planner for a blob that remains in the mmap fallback.
inline constexpr uint64_t kNoCacheComplement = ~uint64_t{0};
/// Required cgroup-v2 usage counters for the conservative cache-reclaim allowance.
struct CgroupMemoryStat {
uint64_t current = 0;
uint64_t inactive_file = 0;
uint64_t file_dirty = 0;
uint64_t file_writeback = 0;
bool valid = false;
};
/// Calculate additional bytes under a finite cgroup limit after reclaiming only clean inactive file cache.
/// Returns false when the required memory.stat counters were unavailable.
bool cgroup_available_bytes(uint64_t limit, const CgroupMemoryStat& stat, uint64_t& bytes);
/// Build compact offsets for experts absent from both the primary GPU cache and an optional second GPU tier.
/// Kept CPU-only so selection and byte accounting can be tested without initializing a GPU.
bool make_cache_complement_plan(
int64_t n_layers, int64_t n_expert, const std::vector<uint64_t>& layer_blob_bytes,
const std::vector<std::pair<int32_t, int32_t>>& primary_gpu_pairs,
const std::vector<std::pair<int32_t, int32_t>>& additional_gpu_pairs,
std::vector<uint64_t>& offsets, uint64_t& bytes, std::string& err);
/// Resolve one blob through the compact copy when present, otherwise preserve its exact mapped-file fallback.
const uint8_t* cache_complement_blob_or_fallback(
size_t index, const std::vector<uint64_t>& offsets, const uint8_t* complement_host,
const uint8_t* mapped_fallback);
/// The resident RAM mode: which GPU-cache slots' experts are kept in RAM too. The prompt path lends the cache's
/// LAST slots (from `lend_from` on; a short prompt lends only the last few), and a lent slot's expert is streamed
/// from RAM during the prompt and copied back into its slot after it. `base_bytes` (every expert no slot holds)
/// must fit `budget`; slots are then added from the end down to `lend_from` while they still fit. Returns the
/// first slot kept in RAM (`slot_bytes.size()` = none), or -1 when `base_bytes` alone exceeds `budget`.
int64_t choose_resident_keep_from(const std::vector<uint64_t>& slot_bytes, uint64_t base_bytes, uint64_t budget,
int64_t lend_from);
/// The adaptive tier swapped `in` into a GPU slot and `out` out of it: `out` takes `in`'s place in the compact copy
/// (the caller copies out's bytes there). False, and nothing changed, unless `in` is in the copy and `out` is not.
bool exchange_cache_complement(std::vector<uint64_t>& offsets, size_t in, size_t out);
} // namespace detail
/// Where one routed expert's bytes come from.
///
/// Phase 2 has NO cache (`phase-2-correct-engine.md`: hit rate `h = 0`), so the only implementation is a
@@ -42,7 +92,7 @@ class ExpertSource {
public:
virtual ~ExpertSource() = default;
/// The 1,382,400-byte blob for `(layer, expert)`, or nullptr if it cannot be produced.
/// The expert-layout blob for `(layer, expert)`, or nullptr if it cannot be produced.
///
/// The pointer only has to stay valid until the next `blob()` call: with `h = 0` every expert is computed
/// immediately and nothing is retained. A CACHING source must return pointers into the cache, not into a
@@ -61,6 +111,10 @@ public:
virtual void begin_layer(int64_t layer, const int32_t* ids, int64_t k) { (void) layer; (void) ids; (void) k; }
/// Plan v0.3 P6: the DEVICE address of a pinned, mapped blob (the GPU can read it over PCIe), or null.
virtual const uint8_t* device_alias(int64_t layer, int64_t expert) const { (void) layer; (void) expert; return nullptr; }
/// Whether the verify window may give the GPU a PCIe share of this layer's misses at all (each expert is still
/// checked with `pinned`). The arena answers per layer through its expert 0; the resident RAM mode's compact
/// copy has no expert 0 when the GPU cache holds it, so it answers for the whole copy.
virtual bool pcie_layer(int64_t layer) const { return device_alias(layer, 0) != nullptr; }
};
/// Plan v0.3 P6: what the GPU computes in a verify window's layer, written by the pool (mapped host memory) right
@@ -270,26 +324,96 @@ public:
FileExpertSource(const FileExpertSource&) = delete;
FileExpertSource& operator=(const FileExpertSource&) = delete;
/// Maps `<pack_dir>/experts.bin` and checks its size against `n_layers * n_expert * BLOB`.
/// Maps `<pack_dir>/experts.bin` and checks its size against the loaded expert layout. Canonical packs use
/// `n_layers * n_expert * BLOB`; native packs use their variable per-layer blob sizes and offsets.
///
/// The size check is not a formality: a short file would fault at the END of a long sequence, and an
/// over-long one means the pack is not the one the geometry came from. Refuses with the two numbers.
bool open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, std::string& err);
/// Pin a compact host mirror of experts absent from a fully filled static GPU cache. The mmap remains open
/// as a fallback for later cache reloads. This is opt-in because the complement may still be a large allocation.
///
/// The resident RAM mode (`--resident-experts`, `--resident-cpu-experts`):
/// - `pin`: page-locked and mapped (`cudaHostAlloc`), so the prompt path copies it by DMA and the verify
/// window may read a share of the misses over PCIe; when the driver refuses, ordinary memory locked in the
/// working set instead. `pin = false` is ordinary pageable memory (the ROCm arm: large pinned allocations
/// can fail there, and it is what the HIP measurements used).
/// - `lend_from_slot` >= 0: the GPU-cache slots from there to the end are the prompt path's lend region; their
/// experts are kept in RAM too, from the last slot down, as far as `available RAM - headroom_bytes` allows
/// (a lent slot's expert is streamed during the prompt and copied back after it).
/// - the rest (the experts no slot holds) must fit that budget, or nothing is allocated and this returns false.
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,
uint64_t headroom_bytes = 8ull << 30);
void close();
bool mapped() const { return base_ != nullptr; }
int64_t blobs() const { return blobs_; }
uint64_t pinned_bytes() const { return complement_pinned_ ? complement_bytes_ : 0; }
uint64_t resident_bytes() const { return complement_bytes_; }
bool complement_pinned() const { return complement_pinned_; }
bool complement_ready() const { return complement_ready_; }
uint64_t locked_bytes() const { return complement_locked_; }
/// Lend-region slots whose experts the compact copy holds (the last ones of the cache).
int64_t resident_lent_slots() const { return complement_lent_slots_; }
// ---- the resident RAM mode and the adaptive tier. A swap puts `in` (held here) into a GPU slot and evicts
// `out` (held only by that slot). Before the slot is overwritten the caller copies it back into an exchange
// buffer and calls `stage_exchange`: `out` is then read from that buffer, and `in` still from here (the CPU
// computes both until the swap lands). Once the slot copy has landed, `commit_exchanges` moves `out` into
// `in`'s place, so the copy keeps holding exactly the experts the GPU does not - with no read of the file.
/// Whether the compact copy holds `(layer, expert)`.
bool has_resident(int64_t layer, int64_t expert) const;
/// Host room for `n` evicted blobs (page-locked when possible). Idempotent for the same or a smaller `n`.
bool reserve_exchanges(int64_t n, std::string& err);
int64_t exchange_capacity() const { return xstage_cap_; }
uint8_t* exchange_buffer(int64_t q) const;
/// Requires `has_resident(layer, in)`, `!has_resident(layer, out)` and `exchange_buffer(q)` holding out's blob.
bool stage_exchange(int64_t layer, int64_t in, int64_t out, int64_t q);
/// After the GPU copies of every staged swap have landed. Returns how many exchanges were applied.
int64_t commit_exchanges();
int64_t exchanges() const { return exchanges_; }
/// With the compact copy ready: blobs read from the mapped file since (what the plain mmap mode may read from
/// the SSD). 0 in a steady resident mode; lend-region experts that did not fit the RAM count here.
int64_t file_reads() const { return file_reads_.load(std::memory_order_relaxed); }
const uint8_t* blob(int64_t layer, int64_t expert) override;
bool pinned(int64_t layer, int64_t expert) const override;
const uint8_t* device_alias(int64_t layer, int64_t expert) const override;
bool pcie_layer(int64_t layer) const override;
/// Blobs touched, for the driver to report. With `h = 0` this is `48 * k` per token and the number is only
/// interesting once Phase 3 makes it not so.
int64_t reads() const override { return reads_; }
private:
const uint8_t* mapped_blob(int64_t layer, int64_t expert) const;
static constexpr uint64_t kNoComplement = detail::kNoCacheComplement;
const uint8_t* base_ = nullptr;
int64_t blobs_ = 0;
int64_t n_layers_ = 0;
int64_t n_expert_ = 0;
uint64_t mapped_bytes_ = 0;
std::vector<uint64_t> layer_offsets_, layer_blob_bytes_;
void* complement_arena_ = nullptr;
const uint8_t* complement_host_ = nullptr;
const uint8_t* complement_device_ = nullptr;
uint64_t complement_bytes_ = 0;
std::vector<uint64_t> complement_offsets_;
bool complement_pinned_ = false;
bool complement_ready_ = false;
uint64_t complement_locked_ = 0; ///< bytes held in the working set (pin refused)
int64_t complement_lent_slots_ = 0;
std::vector<const uint8_t*> override_; ///< staged exchanges: an evicted expert read from its exchange buffer
struct Exchange { size_t in, out; int64_t q; uint64_t bytes; };
std::vector<Exchange> staged_;
uint8_t* xstage_ = nullptr; ///< exchange buffers, `xstage_cap_ x xstage_blob_`
bool xstage_pinned_ = false;
int64_t xstage_cap_ = 0;
uint64_t xstage_blob_ = 0;
int64_t exchanges_ = 0;
std::atomic<int64_t> file_reads_{0};
int64_t reads_ = 0;
#if defined(_WIN32)
void* file_ = nullptr;
@@ -325,7 +449,7 @@ public:
/// Allocates and loads `<pack_dir>/experts.bin`. Prints nothing; the caller reports `note()` and the load
/// rate, because those are the two numbers that say whether the arena is the one that was asked for.
bool open(const std::string& pack_dir, int64_t n_layers, int64_t n_expert, int threads, std::string& err,
uint64_t max_pinned_bytes = 0);
uint64_t max_pinned_bytes = 0, const std::string& shared_arena_file = {});
/// Plan v0.3 P6: a native pack without experts.bin takes its experts from the model's shard 1.
void set_gguf(const std::string& shard1) { gguf_ = shard1; }
void close();
@@ -341,6 +465,12 @@ public:
/// only true if the engine says what it got.
const std::string& note() const { return note_; }
double load_gib_per_second() const { return gib_per_s_; }
// Loader fix: the load, split. `load_seconds()` is the wall clock of the load loop; the other two are
// sums over the reader threads (see LoadStats), so on their own they say how much of that wall was spent
// waiting for the disk and how much in memcpy + FNV-1a.
double load_seconds() const { return load_seconds_; }
double load_read_seconds() const { return load_read_s_; }
double load_copy_seconds() const { return load_copy_s_; }
private:
void* arena_ = nullptr; ///< the PinnedArena, owned
@@ -352,6 +482,9 @@ private:
int64_t reads_ = 0;
std::string note_;
double gib_per_s_ = 0.0;
double load_seconds_ = 0.0;
double load_read_s_ = 0.0;
double load_copy_s_ = 0.0;
uint64_t pinned_bytes_ = 0;
std::string gguf_;
};
+15
View File
@@ -40,6 +40,8 @@
#include "strata/kernels/ngram.hpp"
#include "strata/kernels/ple.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstdint>
#include <string>
@@ -211,6 +213,10 @@ struct QsaState {
bool kv_q4 = false;
uint8_t* k_q4 = nullptr;
uint8_t* v_q4 = nullptr;
/// Hybrid K8V4: K in INT8 (unrotated - the scores stay exact), V in rotated Q4_0 (kv_q4.hpp): 816 B per cell.
/// 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;
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;
@@ -276,8 +282,17 @@ bool qsa_kv_int8();
/// PR #21: store K/V as Q4_0 after a Hadamard rotation (`--kv q4_0`): 576 B per cell, vs 1,056 in INT8.
void qsa_set_kv_q4(bool enabled);
bool qsa_kv_q4();
/// Hybrid K8V4 (`--kv k8v4`): K in INT8, V in rotated Q4_0 - 816 B per cell. Not with --kv-resident.
void qsa_set_kv_hybrid(bool enabled);
bool qsa_kv_hybrid();
/// The state's KV format for the block-moving functions of kv_stream.hpp (kKvF16 / kKvInt8 / kKvQ4).
inline int qsa_kv_format(const QsaState& st) {
// A hybrid K8V4 state is mode 0 only and never reaches the block movers; refuse rather than let it
// fall through to kKvF16 - a wrong layout silently applied is worse than a hard stop (PR review).
if (st.kv_hybrid) {
std::fprintf(stderr, "strata: qsa_kv_format: a hybrid K8V4 state must never reach the block movers\n");
std::exit(1); // the kernels' own "unsupported geometry" convention (kv_q8.cu, qsa_decode_attn.cu)
}
return st.kv_q4 ? strata::kernels::kKvQ4 : st.kv_int8 ? strata::kernels::kKvInt8 : strata::kernels::kKvF16;
}
uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st,
+40 -2
View File
@@ -21,6 +21,7 @@
#include "strata/core/layer.hpp"
#include "strata/core/session.hpp"
#include "strata/kernels/sampler.hpp"
#include <cuda_runtime.h>
@@ -53,6 +54,9 @@ public:
/// KV streaming: refill the ring of the drafter's window from its host copy for a sequence that continues at
/// `upto` (a conversation-cache resume). No-op unless the drafter's K/V is a ring.
void kv_restore(int64_t upto);
/// The VRAM bind() will allocate for a native head of `head_row_bytes` per vocabulary row: the draft logits and
/// the draft head over rt/draft_vocab.bin's subset. The expert cache is sized before bind(), so it reserves this.
uint64_t bind_bytes(uint64_t head_row_bytes, int64_t n_vocab) const;
/// The main model's embedding and head, and the verify window's final residuals (T rows, hc*n_embd each).
bool bind(const WeightTable& wt, const NativeHead* head, const float* window_R, std::string& err);
@@ -70,16 +74,50 @@ public:
bool draft_first(int T, const float* R_row, int32_t token, int64_t cell, int32_t* drafts, std::string& err,
float* probs = nullptr, float min_p = 0.0f, int* n_drafts = nullptr);
/// COUPLED DRAFT SAMPLING (core/coupled_draft.hpp; STRATA_SPEC_COUPLED=1, set up by bind()): the request's
/// sampling. A sampled request (temperature > 0, not greedy) then drafts by SAMPLING with the target's chain and
/// the target's Philox draw for the verifying row, and `probs` is the draft's probability under that chain; a
/// greedy one keeps the argmax drafts and their graphs. A no-op when the switch is off.
void set_draft_sampling(const strata::kernels::SamplerParams& sp);
/// Coupled mode with penalties: the history the next draft() chain starts from - what the session holds after the
/// window's commit (`tail`) and the window's pick at its last accepted row (`next`, the next window's row 0).
void set_draft_history(const int32_t* tail, int64_t n_tail, int32_t next);
bool coupled() const { return coupled_active_; }
double ms_draft = 0, ms_prefill = 0;
int64_t rounds = 0;
/// E-9: the prompt path computes this layer's prompt K/V in batches (Prefill::draft_kv): its tensors, its K/V
/// state, the first cell a round can still read, its device, and a wait for its own stream.
const float* tensor_f32(const char* name) const { return f32(name); }
const uint16_t* tensor_bf16(const char* name) const { return bf16(name); }
const void* tensor_q8(const char* name) const { return q8(name); }
QsaState& kv_state_rw() { return st_; }
int64_t first_needed() const { return (window_ > 0 && prompt_len_ > 0) ? prompt_len_ - window_ - 64 : 0; }
int device() const { return device_; }
bool idle(std::string& err) {
if (cs_ && cudaStreamSynchronize(cs_) != cudaSuccess) { err = "mtp: its stream failed"; return false; }
return true;
}
private:
bool record_forward(int T, int step_row0, cudaStream_t cs, std::string& err);
bool capture_prefill(int T, std::string& err);
bool capture_prefill_dev(int T, std::string& err); ///< E-4: without the mapped staging (inputs copied on device)
bool capture_round(int T, std::string& err);
bool capture_step(int j, std::string& err);
bool capture_round(int T, bool coupled, std::string& err);
bool capture_step(int j, bool coupled, std::string& err);
cudaGraphExec_t step_exec_[9] = {};
// coupled draft sampling: its own round/step graphs (the argmax ones stay as they were), the request's
// parameters and the penalty ring (mapped staging + device copies), the split scratch, token id -> subset index
bool setup_coupled(std::string& err);
cudaGraphExec_t round_exec_c_[9] = {};
cudaGraphExec_t step_exec_c_[9] = {};
bool coupled_ok_ = false, coupled_active_ = false;
bool coupled_rec_ = false; ///< record_forward: the full layer ends in the coupled sampler (draft coupled_j_)
int coupled_j_ = 0;
strata::kernels::SamplerParams *h_cparams_ = nullptr, *m_cparams_ = nullptr, *cparams_ = nullptr;
int32_t *h_chist_ = nullptr, *m_chist_ = nullptr, *cring_ = nullptr, *dinv_ = nullptr;
void* cscratch_ = nullptr;
const float* f32(const char* name) const;
const uint16_t* bf16(const char* name) const;
const void* q8(const char* name) const;
+33 -1
View File
@@ -40,8 +40,15 @@ struct PinnedArena {
/// Plan v0.3 P6: slices of different sizes (one per layer of a native pack), given as their start offsets
/// followed by the end of the last one. `slice_starts` holds the registered ones.
/// `max_pinned_bytes`: optional cap on CUDA registration. 0 preserves the normal unrestricted path.
PinnedArena(uint64_t bytes, const std::vector<uint64_t>& bounds, uint64_t max_pinned_bytes = 0);
/// `shared_file`: on Linux, use a file-backed MAP_SHARED mapping instead of anonymous memory.
/// `shared_pack_hash` identifies the pack that is allowed to populate that backing. The file carries a
/// small header and is refused when its stored hash does not match. Empty `shared_file` preserves the
/// existing allocation path. Population/coordination and backing-file lifetime remain the caller's job.
PinnedArena(uint64_t bytes, const std::vector<uint64_t>& bounds, uint64_t max_pinned_bytes = 0,
const std::string& shared_file = {}, uint64_t shared_pack_hash = 0);
std::vector<uint64_t> slice_starts;
void* mapping_base = nullptr; ///< actual mapping start; differs from base when a shared-file header exists
uint64_t mapping_bytes = 0; ///< bytes to release from mapping_base
~PinnedArena();
PinnedArena(const PinnedArena&) = delete;
PinnedArena& operator=(const PinnedArena&) = delete;
@@ -52,15 +59,40 @@ struct PinnedArena {
struct LoadStats {
double seconds = 0.0;
// Loader fix: the two halves of `seconds`, both SUMMED OVER THE READER THREADS rather than wall clock, so
// either can exceed `seconds` when the threads overlap. They exist to separate the read from the copy and
// the hash: an 8 MiB chunk that reaches the disk as ~2048 4 KiB reads spends its time in the first one.
double read_seconds = 0.0; // inside the read call only: no seek, no copy, no hash
double copy_seconds = 0.0; // memcpy + FNV-1a
// A short read is not a slow load, it is a WRONG one: the caller must refuse the pack rather than run
// on an arena whose tail was never written. `seconds` alone cannot say that (it is -1.0 on failure, but
// the caller's own check is on the REQUESTED byte count, which a refused read does not change).
bool ok = true; // false: the load failed, see `error`
std::string error; // why it failed, for the caller's message
uint64_t bytes = 0;
uint64_t layers = 0;
std::vector<uint64_t> layer_checksums; // one FNV-1a per layer
double gib_per_second() const { return seconds > 0 ? (double) bytes / (1024.0 * 1024 * 1024) / seconds : 0.0; }
// Aggregate rate of the readers WHILE THEY WERE INSIDE THE READ CALL: `bytes` over the mean per-thread
// read time. `gib_per_second()` above divides the same bytes by the wall clock of the whole loop, which
// also contains the copy and the hash, so the two differ by how much of the loop was not I/O.
double read_gib_per_second(int threads) const {
if (read_seconds <= 0.0 || threads < 1) return 0.0;
return (double) bytes / (1024.0 * 1024 * 1024) / (read_seconds / (double) threads);
}
};
// Load `layers` layers of the expert arena into `dst` with `threads` readers, `chunk` bytes at a time.
// Each thread opens its OWN handle and seeks, which is the portable form of parallel pread: a shared handle
// needs a lock around the seek and defeats the parallelism on Windows.
//
// The reads go through `fread()` on a per-thread `FILE*`, NOT through `std::ifstream`. MSVC's
// `basic_filebuf::xsgetn` splits every request larger than `_INTERNAL_BUFSIZ - 1` into 4095-byte `fread()`
// calls, so an 8 MiB `chunk` reached the disk as ~2048 4 KiB operations and the chunking did nothing at all
// (measured on a 42.9 GB pack: 1222 s, 4096 bytes per operation, 0.03 GiB/s). `fread()` hands a request
// larger than the stream buffer straight to `_read()`/`ReadFile()`, which is what `chunk` is for. The
// control flow, the copy, the per-layer FNV-1a and therefore the checksums are unchanged, so a pack loaded
// by the old and the new reader must produce identical `layer_checksums`.
LoadStats load_experts(const std::string& path, uint8_t* dst, uint64_t blob_bytes, uint64_t blobs_per_layer,
uint64_t layers, int threads, uint64_t chunk);
/// Plan v0.3 P6: the same with one byte range per layer (`layer_off[L]`, `layer_bytes[L]`).
+25 -4
View File
@@ -45,6 +45,20 @@ struct SessionState {
float* R = nullptr; ///< alias of `block.R`, named for what it means at this level
int64_t k = 10; ///< experts per token
// **THE LAYER-RANGE CARVE (multi-GPU).** A split stage's session used to carve state for ALL 48 layers
// (every QSA KV pool at max_cells, every GDN recurrence) while running only its slice - the same
// whole-model-sized-buffer disease the QSA chunked-prefill PR cured upstream. A session now owns
// [layer_lo, layer_hi): `qsa_states`/`gdn_state` keep GLOBAL ordinal indexing, the arrays simply hold
// fewer rows, so per-layer consumers subtract `gdn_ord0`, and every old model-level `qsa_states[0]` use
// (the shared RoPE table, the prefill staging identity, the MTP rope borrow) reads `qsa_states[primary]`.
// A range with no QSA layer still carves ONE primary state so those model-level uses stay valid.
int64_t layer_lo = 0, layer_hi = 0; ///< resolved by `session_init` (hi = n_layers for the full range)
int64_t qsa_ord0 = 0; ///< global QSA ordinal of the first allocated state
int64_t qsa_alloc = 0; ///< allocated QSA states (>= 1 whenever the model has any)
int64_t gdn_ord0 = 0; ///< global GDN ordinal of `gdn_state` row 0
int64_t gdn_alloc = 0; ///< allocated GDN rows
int qsa_primary() const { return (int) qsa_ord0; }
/// The doorbell the host loop polls. Null until a caller provides one - the engine runs without it, and
/// neither `session_token` nor `session_replay` looks at it.
const Doorbell* db = nullptr;
@@ -76,9 +90,15 @@ struct SessionState {
/// Bytes for a whole session at `max_cells` of context. Every layer's state is sized at once, because P2.T10
/// requires ZERO token-path allocations - a `cudaMalloc` that happened on the first token of a longer sequence
/// would satisfy every test here and fail that one.
uint64_t session_bytes(const ModelGeometry& g, int64_t max_cells, int64_t k);
/// Carves `base` (DEVICE memory) into `s`. Returns the bytes used.
uint64_t session_init(const ModelGeometry& g, int64_t max_cells, int64_t k, void* base, SessionState& s);
///
/// [layer_lo, layer_hi) carves only that range's per-layer state (a split stage runs a slice of the model);
/// the default full range is byte-identical to the old whole-model carve. Pure arithmetic - safe to call for
/// a candidate range before anything is allocated, which is how the layer-split search prices a placement.
uint64_t session_bytes(const ModelGeometry& g, int64_t max_cells, int64_t k, int64_t layer_lo = 0,
int64_t layer_hi = -1);
/// 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);
/// 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);
@@ -207,7 +227,8 @@ struct SessionGraphs {
/// replay re-reads every token - so the position is data, not an argument. That is the whole reason rounds
/// 211-214 moved the per-token counts into device buffers.
bool session_capture(const WeightTable& tables, const ModelGeometry& g, SessionState& s, const float* parts,
SessionGraphs& gr, std::string& err, bool split = false);
SessionGraphs& gr, std::string& err, bool split = false, int64_t layer_lo = 0,
int64_t layer_hi = -1);
/// One token by REPLAYING the captured graphs. Identical arithmetic to `session_token`; the only difference is
/// that ~2,000 kernel launches become 48 graph launches.
+11
View File
@@ -32,6 +32,7 @@
#include <cstdint>
#include <string>
#include <vector>
namespace strata::core {
@@ -124,9 +125,13 @@ public:
/// arena directly, 2 = a copy kernel stages it inside the graph (no API calls on the pool's thread; best when
/// the CPU is RAM-bound, Q2_0). Set before the first `run`.
void set_pcie_mode(int mode) { sink_.pcie_mode = mode; }
/// the pool never plans a PCIe share (--pcie-frac 0): the window skips that path. Before the first run.
double ms_wait = 0, ms_pool = 0, ms_host = 0, ms_commit = 0;
int64_t windows = 0;
/// STRATA_VERIFY_PROFILE=1 - GPU stage times of the windows since the last call (ms per
/// window), as one line; empty when off.
std::string profile_report();
private:
bool capture(int T, std::string& err);
@@ -151,6 +156,12 @@ 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
bool prof_on_ = false;
unsigned long long* prof_ = nullptr; // device: n_layers * kProfPer + 4 stamps
std::vector<unsigned long long> prof_h_;
double prof_sum_[2][kProfPer] = {}; // [GDN / QSA layers][stage]
int64_t prof_windows_ = 0;
const WeightTable* wt_ = nullptr;
const ModelGeometry* g_ = nullptr;
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <hipblas/hipblas.h>
#define CUBLAS_COMPUTE_32F HIPBLAS_COMPUTE_32F
#define CUBLAS_DEFAULT_MATH HIPBLAS_DEFAULT_MATH
#define CUBLAS_GEMM_DEFAULT HIPBLAS_GEMM_DEFAULT
#define CUBLAS_OP_N HIPBLAS_OP_N
#define CUBLAS_OP_T HIPBLAS_OP_T
#define CUBLAS_STATUS_SUCCESS HIPBLAS_STATUS_SUCCESS
#define CUDA_R_16BF HIP_R_16BF
#define CUDA_R_16F HIP_R_16F
#define CUDA_R_32F HIP_R_32F
#define cublasCreate hipblasCreate
#define cublasDestroy hipblasDestroy
#define cublasGemmEx hipblasGemmEx
#define cublasHandle_t hipblasHandle_t
#define cublasSetMathMode hipblasSetMathMode
#define cublasSetStream hipblasSetStream
#define cublasSetWorkspace hipblasSetWorkspace
#define cublasStatus_t hipblasStatus_t
+2
View File
@@ -0,0 +1,2 @@
#pragma once
#include <hip/hip_fp16.h>
+105
View File
@@ -0,0 +1,105 @@
#pragma once
// Included only by STRATA_ENABLE_HIP builds. CUDA builds use NVIDIA headers.
#include <hip/hip_runtime.h>
// Do not let HIP's legacy macro corrupt libstdc++ attribute names.
#ifdef __noinline__
#undef __noinline__
#endif
#define cudaDevAttrMaxSharedMemoryPerBlockOptin hipDeviceAttributeMaxSharedMemoryPerBlock
#define cudaDevAttrMultiProcessorCount hipDeviceAttributeMultiprocessorCount
#define cudaDevAttrClockRate hipDeviceAttributeClockRate
#define cudaDevAttrComputeCapabilityMajor hipDeviceAttributeComputeCapabilityMajor
#define cudaDeviceGetAttribute hipDeviceGetAttribute
#define cudaDeviceProp hipDeviceProp_t
#define cudaDeviceSynchronize hipDeviceSynchronize
#define cudaDriverGetVersion hipDriverGetVersion
#define cudaErrorNotReady hipErrorNotReady
#define cudaErrorStreamCaptureUnsupported hipErrorStreamCaptureUnsupported
#define cudaError_t hipError_t
#define cudaEventCreate hipEventCreate
#define cudaEventCreateWithFlags hipEventCreateWithFlags
#define cudaEventDestroy hipEventDestroy
#define cudaEventDisableTiming hipEventDisableTiming
#define cudaEventElapsedTime hipEventElapsedTime
#define cudaEventQuery hipEventQuery
#define cudaEventRecord hipEventRecord
#define cudaEventSynchronize hipEventSynchronize
#define cudaEvent_t hipEvent_t
#define cudaFree hipFree
#define cudaFreeHost hipFreeHost
#define cudaFuncAttributeMaxDynamicSharedMemorySize hipFuncAttributeMaxDynamicSharedMemorySize
#define cudaGetDevice hipGetDevice
#define cudaGetDeviceCount hipGetDeviceCount
#define cudaGetDeviceProperties hipGetDeviceProperties
#define cudaGetErrorString hipGetErrorString
#define cudaGetLastError hipGetLastError
#define cudaGraphDestroy hipGraphDestroy
#define cudaGraphExecDestroy hipGraphExecDestroy
#define cudaGraphExec_t hipGraphExec_t
#define cudaGraphGetNodes hipGraphGetNodes
#define cudaGraphLaunch hipGraphLaunch
#define cudaGraphUpload hipGraphUpload
#define cudaGraph_t hipGraph_t
#define cudaHostAlloc hipHostMalloc
#define cudaHostAllocDefault hipHostAllocDefault
#define cudaHostAllocMapped hipHostAllocMapped
#define cudaHostAllocPortable hipHostAllocPortable
#define cudaHostGetDevicePointer hipHostGetDevicePointer
#define cudaHostRegister hipHostRegister
#define cudaHostRegisterMapped hipHostRegisterMapped
#define cudaHostRegisterPortable hipHostRegisterPortable
#define cudaHostUnregister hipHostUnregister
#define cudaLaunchHostFunc hipLaunchHostFunc
#define cudaMalloc hipMalloc
#define cudaMallocHost(...) (::strata::hip_compat::malloc_host(__VA_ARGS__))
#define cudaMemGetInfo hipMemGetInfo
#define cudaMemcpy hipMemcpy
#define cudaMemcpy2DAsync hipMemcpy2DAsync
#define cudaMemcpyAsync hipMemcpyAsync
#define cudaMemcpyDefault hipMemcpyDefault
#define cudaMemcpyDeviceToDevice hipMemcpyDeviceToDevice
#define cudaMemcpyDeviceToHost hipMemcpyDeviceToHost
#define cudaMemcpyHostToDevice hipMemcpyHostToDevice
#define cudaMemset hipMemset
#define cudaMemsetAsync hipMemsetAsync
#define cudaPeekAtLastError hipPeekAtLastError
#define cudaRuntimeGetVersion hipRuntimeGetVersion
#define cudaSetDevice hipSetDevice
#define cudaStreamBeginCapture hipStreamBeginCapture
#define cudaStreamCaptureModeThreadLocal hipStreamCaptureModeThreadLocal
#define cudaStreamCaptureStatus hipStreamCaptureStatus
#define cudaStreamCaptureStatusNone hipStreamCaptureStatusNone
#define cudaStreamCreate hipStreamCreate
#define cudaStreamCreateWithFlags hipStreamCreateWithFlags
#define cudaStreamDestroy hipStreamDestroy
#define cudaStreamEndCapture hipStreamEndCapture
#define cudaStreamIsCapturing hipStreamIsCapturing
#define cudaStreamNonBlocking hipStreamNonBlocking
#define cudaStreamQuery hipStreamQuery
#define cudaStreamSynchronize hipStreamSynchronize
#define cudaStreamWaitEvent hipStreamWaitEvent
#define cudaStream_t hipStream_t
#define cudaSuccess hipSuccess
namespace strata::hip_compat {
template <typename T>
inline hipError_t malloc_host(T** pointer, size_t bytes) {
return hipHostMalloc(reinterpret_cast<void**>(pointer), bytes, hipHostMallocDefault);
}
} // namespace strata::hip_compat
template <typename Kernel>
inline hipError_t cudaFuncSetAttribute(Kernel kernel, hipFuncAttribute attribute, int value) {
return hipFuncSetAttribute(reinterpret_cast<const void*>(kernel), attribute, value);
}
inline hipError_t cudaGraphInstantiate(hipGraphExec_t* exec, hipGraph_t graph, unsigned long long flags) {
return hipGraphInstantiateWithFlags(exec, graph, flags);
}
inline hipError_t cudaGraphInstantiate(hipGraphExec_t* exec, hipGraph_t graph,
hipGraphNode_t* error, char* log, size_t size) {
return hipGraphInstantiate(exec, graph, error, log, size);
}
#define __trap() __builtin_trap() // the compiled-out sm_80 paths (never selected on AMD)
#define cudaMemcpyToSymbol(symbol, ...) hipMemcpyToSymbol(HIP_SYMBOL(symbol), __VA_ARGS__)
#include "intrinsics.hpp"
@@ -0,0 +1,2 @@
#pragma once
#include "cuda_runtime.h"
+126
View File
@@ -0,0 +1,126 @@
#pragma once
// CUDA device intrinsics used by Strata kernels that HIP does not provide on RDNA3 / RDNA4 (wave32).
// This header is included only from the HIP cuda_runtime compatibility shim.
#if defined(__HIPCC__)
#include <cstdint>
namespace strata::hip_compat {
__device__ __forceinline__ int signed_byte(uint32_t word, int lane) {
const unsigned value = (word >> (lane * 8)) & 0xffu;
return value < 0x80u ? static_cast<int>(value) : static_cast<int>(value) - 0x100;
}
// CUDA's signed __dp4a: four signed byte products accumulated modulo 2^32.
__device__ __forceinline__ int dp4a(int a, int b, int c) {
#if (defined(__gfx1100__) || defined(__gfx1101__) || defined(__gfx1102__) || defined(__gfx1200__) || \
defined(__gfx1201__)) && __has_builtin(__builtin_amdgcn_sudot4)
// RDNA3 and RDNA4 expose the signed/unsigned dot4 form (v_dot4_i32_iu8). Mark
// 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);
#else
const uint32_t ua = static_cast<uint32_t>(a);
const uint32_t ub = static_cast<uint32_t>(b);
uint32_t sum = static_cast<uint32_t>(c);
#pragma unroll
for (int lane = 0; lane < 4; ++lane)
sum += static_cast<uint32_t>(signed_byte(ua, lane) * signed_byte(ub, lane));
return static_cast<int>(sum);
#endif
}
// CUDA's packed byte subtract wraps independently in each unsigned byte lane.
__device__ __forceinline__ int vsub4(int a, int b) {
const uint32_t ua = static_cast<uint32_t>(a);
const uint32_t ub = static_cast<uint32_t>(b);
uint32_t out = 0;
#pragma unroll
for (int lane = 0; lane < 4; ++lane) {
const uint32_t x = (ua >> (lane * 8)) & 0xffu;
const uint32_t y = (ub >> (lane * 8)) & 0xffu;
out |= ((x - y) & 0xffu) << (lane * 8);
}
return static_cast<int>(out);
}
// CUDA's packed signed-byte saturating subtract.
__device__ __forceinline__ int vsubss4(int a, int b) {
const uint32_t ua = static_cast<uint32_t>(a);
const uint32_t ub = static_cast<uint32_t>(b);
uint32_t out = 0;
#pragma unroll
for (int lane = 0; lane < 4; ++lane) {
int value = signed_byte(ua, lane) - signed_byte(ub, lane);
value = value < -128 ? -128 : (value > 127 ? 127 : value);
out |= (static_cast<uint32_t>(value) & 0xffu) << (lane * 8);
}
return static_cast<int>(out);
}
// CUDA's four-lane byte compare, returning 0xff for each unequal lane and 0 otherwise.
__device__ __forceinline__ int vcmpne4(int a, int b) {
const uint32_t ua = static_cast<uint32_t>(a);
const uint32_t ub = static_cast<uint32_t>(b);
uint32_t out = 0;
#pragma unroll
for (int lane = 0; lane < 4; ++lane) {
if (((ua >> (lane * 8)) & 0xffu) != ((ub >> (lane * 8)) & 0xffu))
out |= 0xffu << (lane * 8);
}
return static_cast<int>(out);
}
// CUDA's mask argument describes participating lanes. The current kernel set uses full
// wave32 masks; reject future partial-mask use instead of silently dropping its semantics.
__device__ __forceinline__ void require_full_wave_mask(uint32_t mask) {
if (mask != 0xffffffffu) __builtin_trap();
}
template <typename T>
__device__ __forceinline__ T shfl_xor_sync(uint32_t mask, T value, int lane_mask, int width = 32) {
require_full_wave_mask(mask);
return __shfl_xor(value, lane_mask, width);
}
template <typename T>
__device__ __forceinline__ T shfl_down_sync(uint32_t mask, T value, unsigned delta, int width = 32) {
require_full_wave_mask(mask);
return __shfl_down(value, delta, width);
}
template <typename T>
__device__ __forceinline__ T shfl_up_sync(uint32_t mask, T value, unsigned delta, int width = 32) {
require_full_wave_mask(mask);
return __shfl_up(value, delta, width);
}
template <typename T>
__device__ __forceinline__ T shfl_sync(uint32_t mask, T value, int source_lane, int width = 32) {
require_full_wave_mask(mask);
return __shfl(value, source_lane, width);
}
__device__ __forceinline__ unsigned ballot_sync(uint32_t mask, int predicate) {
require_full_wave_mask(mask);
return static_cast<unsigned>(__ballot(predicate));
}
} // namespace strata::hip_compat
#define __dp4a(a, b, c) (::strata::hip_compat::dp4a((a), (b), (c)))
#define __vsub4(a, b) (::strata::hip_compat::vsub4((a), (b)))
#define __vsubss4(a, b) (::strata::hip_compat::vsubss4((a), (b)))
#define __vcmpne4(a, b) (::strata::hip_compat::vcmpne4((a), (b)))
#define __shfl_xor_sync(...) (::strata::hip_compat::shfl_xor_sync(__VA_ARGS__))
#define __shfl_down_sync(...) (::strata::hip_compat::shfl_down_sync(__VA_ARGS__))
#define __shfl_up_sync(...) (::strata::hip_compat::shfl_up_sync(__VA_ARGS__))
#define __shfl_sync(...) (::strata::hip_compat::shfl_sync(__VA_ARGS__))
#define __ballot_sync(mask, predicate) (::strata::hip_compat::ballot_sync((mask), (predicate)))
// AMD's sleep instruction accepts only 0..15; the synchronization loops use it as a
// backoff hint, so use its smallest portable delay independently of CUDA cycle counts.
#define __nanosleep(cycles) __builtin_amdgcn_s_sleep(1)
#endif // defined(__HIPCC__)
+16 -1
View File
@@ -19,7 +19,7 @@
#include <cstdint>
#include <cstring>
#if defined(__CUDACC__)
#if defined(__CUDACC__) || defined(__HIPCC__)
#define STRATA_BF16_HD __host__ __device__
#else
#define STRATA_BF16_HD
@@ -28,9 +28,20 @@
namespace strata::kernels {
/// Round-to-nearest-even f32 -> bf16, returned as raw bits in the LOW half of the uint16.
///
/// **A NaN IS PASSED THROUGH, QUIETED, AS `ggml_compute_fp32_to_bf16` DOES.** The rounding add alone
/// carries out of the mantissa for a NaN: 0x7FFFFFFF became 0x8000 (-0) and 0x7F800001 became 0x7F80 (+inf), so
/// a NaN upstream was masked instead of propagated. The test is on the magnitude bits, the result keeps the
/// sign and the top mantissa bits and sets the quiet bit (64 = bit 6 of the bf16). Every non-NaN input,
/// infinities included, rounds exactly as before.
STRATA_BF16_HD inline uint16_t bf16_from_f32(float f) {
uint32_t i;
#if defined(__HIP_DEVICE_COMPILE__)
__builtin_memcpy(&i, &f, 4);
#else
std::memcpy(&i, &f, 4);
#endif
if ((i & 0x7FFFFFFFu) > 0x7F800000u) return (uint16_t) ((i >> 16) | 64u);
i = (i + ((i >> 16) & 1u) + 0x7FFFu) & 0xFFFF0000u;
return (uint16_t) (i >> 16);
}
@@ -39,7 +50,11 @@ STRATA_BF16_HD inline uint16_t bf16_from_f32(float f) {
STRATA_BF16_HD inline float f32_from_bf16(uint16_t h) {
const uint32_t i = (uint32_t) h << 16;
float f;
#if defined(__HIP_DEVICE_COMPILE__)
__builtin_memcpy(&f, &i, 4);
#else
std::memcpy(&f, &i, 4);
#endif
return f;
}
+4
View File
@@ -44,5 +44,9 @@ void bf16_gemv_split(const uint16_t* x, const uint16_t* w, float* y, int64_t n_i
/// No allocations or synchronization, including when stream is null (the CUDA default stream).
void bf16_gemv_fp32_mmvf(const float* x, const uint16_t* w, float* y,
int64_t n_in, int64_t n_out, void* stream);
/// bf16_gemv_fp32_mmvf for n_tok (1..8) activation rows x[t * ldx], outputs y[t * ldy + j]; one launch,
/// the weight read once, every output bit-identical to its own single-row call.
void bf16_gemv_fp32_mmvf_multi(const float* x, int64_t ldx, const uint16_t* w, float* y, int64_t ldy,
int64_t n_in, int64_t n_out, int n_tok, void* stream);
} // namespace strata::kernels
+42
View File
@@ -0,0 +1,42 @@
// include/strata/kernels/dp4a.hpp - the two instructions the kernels use that Pascal (sm_60) does not have.
//
// The engine is written for compute capability 7.5 and newer and refuses older cards at CMake configure time
// (a Pascal build passes -DSTRATA_EXPERIMENTAL_SM60=ON). A GP100-class card is compute capability 6.0, which is
// missing exactly two things the i-quant kernels rely on:
//
// * `__dp4a`, a byte-wise dot product, available from 6.1 (Pascal GP10x/GV11x). Its fallback below is
// llama.cpp's own (`ggml/src/ggml-cuda/common.cuh`), which is the reference for the kernels in this
// directory: they are transcribed from llama.cpp's vecdotq.cuh, and that wrapper's operands are the same
// sites. It reinterprets the operands as SIGNED bytes and accumulates in int32, which is what `__dp4a`
// does for these calls, so the fallback is bit-exact rather than merely close.
// * `__nanosleep`, available from 7.0 (Volta). It only paces single-thread doorbell waits, so a loop that
// spins without it is correct, merely busier.
//
// Both are compile-time selections on `__CUDA_ARCH__`, so one source tree builds for Pascal and for RTX
// 20/30/40/50 without a #define at the call sites. A HIP build never takes either fallback: `__CUDA_ARCH__` is
// undefined there, so `STRATA_DP4A` is `__dp4a` (which `hip_compat/intrinsics.hpp` provides) and the pause is
// HIP's own `__nanosleep`.
#pragma once
#include <cstdint>
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 610
__device__ __forceinline__ int strata_dp4a(const int a, const int b, const int c) {
const int8_t* a8 = (const int8_t*) &a;
const int8_t* b8 = (const int8_t*) &b;
return c + a8[0] * b8[0] + a8[1] * b8[1] + a8[2] * b8[2] + a8[3] * b8[3];
}
#define STRATA_DP4A(a, b, c) strata_dp4a((a), (b), (c))
#else
#define STRATA_DP4A(a, b, c) __dp4a((a), (b), (c))
#endif
/// Yield the thread while a doorbell flag is being polled. The length of the pause is a backoff hint, not a
/// contract, and only pre-Volta CUDA has no `__nanosleep` at all.
__device__ __forceinline__ void strata_spin_pause() {
#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 700
// sm_6x: the loop spins. Every call site is a single-thread doorbell wait, so nothing else is delayed.
#else
__nanosleep(100);
#endif
}
+4
View File
@@ -102,6 +102,10 @@ void doorbell_wait(const uint32_t* d_flag, const uint32_t* d_seq, void* stream);
/// device memory with a kernel, so the handoff stays on the compute queue (a memcpy node is a copy-engine
/// operation, which WDDM submits separately and which measured 67 flushes per token).
void copy_from_mapped(float* dst, const float* src, int64_t n, void* stream);
/// `rows` rows of `width` floats from mapped memory, except the rows listed in hit_rows[0, *count) (device),
/// which are written +0.0 instead (a verify window's GPU-computed entries: the pool leaves zeros there).
void copy_rows_from_mapped(float* dst, const float* src, int64_t rows, int64_t width, const int32_t* hit_rows,
const int32_t* count, void* stream);
/// Plan v0.3 P3: the doorbell's payload and its ring in ONE kernel. Copies `x` (n floats), `ids` and `weights`
/// (k each) into the mapped host regions, fences, and increments the mapped sequence number - replacing three
+9 -1
View File
@@ -25,7 +25,7 @@
#include <cstdint>
#include <cstring>
#if defined(__CUDACC__)
#if defined(__CUDACC__) || defined(__HIPCC__)
#define STRATA_HD __host__ __device__
#else
#define STRATA_HD
@@ -36,7 +36,11 @@ namespace strata::kernels {
/// Round-to-nearest-even f32 -> fp16, returned as raw bits.
STRATA_HD inline uint16_t f16_from_f32(float f) {
uint32_t x;
#if defined(__HIP_DEVICE_COMPILE__)
__builtin_memcpy(&x, &f, 4);
#else
std::memcpy(&x, &f, 4);
#endif
const uint32_t sign = (x >> 16) & 0x8000u;
const uint32_t rawexp = (x >> 23) & 0xFFu;
int exp = (int) rawexp - 127 + 15;
@@ -89,7 +93,11 @@ STRATA_HD inline float f32_from_f16(uint16_t h) {
out = sign | ((ex - 15 + 127) << 23) | (man << 13);
}
float f;
#if defined(__HIP_DEVICE_COMPILE__)
__builtin_memcpy(&f, &out, 4);
#else
std::memcpy(&f, &out, 4);
#endif
return f;
}
+2 -1
View File
@@ -46,6 +46,7 @@ void fused_gr_read(const FusedGrArgs& a, void* stream);
/// weight pointers and eps must be the same for every t); `xn_scratch` is n_tok * hc * n_embd floats. Every
/// token's outputs are bitwise `fused_gr_read(a[t])`.
constexpr int kFusedGrMaxT = 8;
void fused_gr_read_multi(const FusedGrArgs* a, int n_tok, float* xn_scratch, void* stream);
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);
} // namespace strata::kernels
+1 -1
View File
@@ -22,7 +22,7 @@ namespace strata::kernels {
void mrope_table_set(const int32_t* device_table);
const int32_t* mrope_table();
#if defined(__CUDACC__)
#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) {
+3
View File
@@ -15,4 +15,7 @@ bool native_moe_combine_enabled();
// Requires a nonnull ordered stream and disjoint output. No allocation or sync.
void native_moe_combine(const float* parts, const float* weights, const float* shared,
float* output, int64_t n_embd, int64_t k, void* stream);
/// n_tok rows (parts [n,k,N], weights [n,k], shared/output [n,N]) in one launch, each as the single call.
void native_moe_combine_multi(const float* parts, const float* weights, const float* shared, float* output,
int64_t n_embd, int64_t k, int n_tok, void* stream);
}
@@ -1,5 +1,6 @@
#pragma once
#include "strata/kernels/qsa.hpp"
#include "strata/kernels/rope_scaling.hpp"
namespace strata::kernels {
@@ -20,6 +21,9 @@ bool native_qsa_indexer_enabled();
// contiguous, with nonnegative pos_base divisible by four. Clear state before
// starting a new sequence. Completed block b rotates at pos_base+4*b; the spare
// always rotates at zero, including nonzero sequence bases, as in the oracle.
// Under rope scaling (rope_scaling.hpp) the zero angle keeps the spare's
// rotation an identity; YaRN's mscale rides in through cos(0) on both the pooled
// keys and the indexer queries, so selection is unchanged.
//
// Explicit nonnull stream; no allocation or synchronization. All spans must be
// aligned and disjoint, remain valid through replay, and contain finite values;
@@ -32,11 +36,11 @@ bool native_qsa_indexer_enabled();
// Host-side positions (not for a captured graph).
void native_qsa_indexer_append_batch(const float* raw, int64_t n, int64_t p0, int32_t pos_base, const float* gamma,
float epsilon, const QsaIndexerBuffers& b, const QsaShapes& s, int64_t max_cells,
float freq_base, void* stream);
const RopeScaling& scaling, void* stream);
void native_qsa_indexer_append(const float* raw, const int32_t* relative_pos_device,
int32_t pos_base, const float* gamma, float epsilon,
const QsaIndexerBuffers& buffers, const QsaShapes& shapes,
int64_t max_cells, float freq_base, void* stream);
int64_t max_cells, const RopeScaling& scaling, void* stream);
} // namespace strata::kernels
+11 -5
View File
@@ -1,14 +1,20 @@
#pragma once
#include "strata/kernels/rope_scaling.hpp"
namespace strata::kernels {
void native_rope_set_enabled(bool enabled);
bool native_rope_enabled();
// Pinned CUDA text-only IMRoPE: F32 rows, 64 rotated channels, no YaRN/frequency
// factors, equal text positions in all four IMRoPE sections. Each device position
// must be nonnegative. The position buffer remains live through graph replay.
// Supports head_dim 128/256 and exact x==out; partial overlap is rejected.
// Pinned CUDA text-only IMRoPE: F32 rows, 64 rotated channels, equal text positions in all four
// IMRoPE sections. Each device position must be nonnegative. The position buffer remains live
// through graph replay. Supports head_dim 128/256 and exact x==out; partial overlap is rejected.
// Explicit stream required. No allocation or synchronization.
//
// The scaling (rope_scaling.hpp) rides in as the resolved process config: none reproduces the
// original contract exactly, linear/YaRN apply ggml's rope_yarn - the interpolation mix, the
// corr_dims ramp along the pairs, and the mscale magnitude correction folded into cos and sin.
// The struct's knobs are process constants, so kernel arguments baked at graph capture stay valid.
void native_rope_apply(const float* x, float* out, int rows, int head_dim,
int n_rot, float freq_base, const int* positions, void* stream);
int n_rot, const RopeScaling& scaling, const int* positions, void* stream);
}
+2
View File
@@ -14,4 +14,6 @@ bool native_router_enabled();
// four-byte aligned and outputs disjoint from each other and the input.
// Requires a nonnull ordered CUDA stream. No allocation or synchronization.
void native_router_top10(const float* logits, int32_t* ids, float* weights, void* stream);
/// n_tok rows at once (logits [n,512], ids/weights [n,10]); each row exactly as the single call.
void native_router_top10_multi(const float* logits, int32_t* ids, float* weights, int n_tok, void* stream);
}
+5
View File
@@ -110,6 +110,9 @@ struct PleIoOptions {
uint32_t max_inflight = 64; ///< outstanding SSD reads (decode needs 16; prefill chunks use more)
uint64_t cache_rows = 1u << 20; ///< bounded row cache: 1,048,576 rows x 90 B ~ 95 MB; 0 disables
bool io_thread = true; ///< reads submitted by a worker thread, not the caller
/// 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;
};
/// The PLE table. Held by pointer-to-impl so this header does not drag `<windows.h>` into every
@@ -144,6 +147,8 @@ public:
bool open(const std::string& gguf_path, std::string& err);
void close();
bool is_open() const;
/// True when `PleIoOptions::lock` was asked for and mlock succeeded (false: pages only pre-touched).
bool locked() const;
uint64_t rows() const;
/// 16 row indices -> 2560 floats. The gathered rows are flattened HEAD-SLOWEST: row h's 160 values
+4 -1
View File
@@ -118,7 +118,10 @@ inline QsaShapes qsa_real_shapes() {
/// The one legal RMSNorm epsilon for this artifact (`attention.layer_norm_rms_epsilon`).
inline float qsa_rms_eps() { return 1e-6f; }
/// `rope.freq_base`, no rope.scaling keys, so freq_scale = 1 and no YaRN.
/// `rope.freq_base`, the DEFAULT frequency base. The artifact ships no `rope.scaling` keys, so the
/// process's rope scaling starts at none - the runtime configuration lives in `rope_scaling.hpp`
/// (`rope_scaling_set`, once at startup, before the table is built and any graph captured), and this
/// constant is what that config's `freq_base` defaults to and what CLI `--rope-freq-base` overrides.
inline double qsa_freq_base() { return 1e7; }
/// The selection width: `min(n_kv, idx_top_k + idx_block - 1)`.
+8
View File
@@ -28,8 +28,16 @@ void qsa_block_scores(const float* pooled, const float* dead, const float* q_idx
int64_t active_blocks = -1); ///< perf-review C-1: > 0 launches only this many blocks (the
///< batch's largest n_bid + 1; not for a captured graph)
/// 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.
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);
/// 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);
} // namespace strata::kernels
+15 -1
View File
@@ -6,11 +6,19 @@
// The cos/sin table is built on the HOST in float64 (see src/kernels/cuda/rope.cu for why) and the kernel is
// a pure rotation over it. The table depends on (n_rot, theta, position) and not on the token, so it is worth
// caching; `build_rope_table` fills `max_pos` positions of `n_rot/2` pairs each.
//
// ROPE SCALING RIDES IN THE TABLE, NOT IN THE KERNEL. The kernels below read cos/sin values and
// cannot tell a scaled table from an unscaled one - with scaling on, the scaled angles (and YaRN's
// mscale magnitude correction, folded into the same values) simply ARE the table. That is why
// neither `rope_neox_apply` nor the indexer's pooling kernel takes any scaling argument: the config
// enters once, here, at build time (`rope_scaling.hpp`).
#pragma once
#include <cstdint>
#if defined(__CUDACC__)
#include "strata/kernels/rope_scaling.hpp"
#if defined(__CUDACC__) || defined(__HIPCC__)
#define STRATA_ROPE_HD __host__ __device__
#else
#define STRATA_ROPE_HD
@@ -38,6 +46,12 @@ STRATA_ROPE_HD inline void rope_neox_pair(float a, float b, float c, float s, fl
void build_rope_table(int n_rot, double theta, int max_pos, float* cos_tab, float* sin_tab);
/// The same table under the process's rope scaling (rope_scaling.hpp). For `RopeScalingType::None`
/// this IS the five-argument builder above - the parity test holds the two to be bit-identical - and
/// for linear/YaRN the scaled angles (YaRN's mscale included) replace them. The math is ggml's,
/// transcribed; see the header of `rope_scaling.hpp` for what arrives from where.
void build_rope_table(int n_rot, const RopeScaling& scaling, int max_pos, float* cos_tab, float* sin_tab);
// `x` and `out` are (rows, head_dim) and `pos` is (rows,) - one position per row, so a batch of heads at
// different sequence positions is one call. `cos_tab`/`sin_tab` are (max_pos, n_rot/2). `out` may equal `x`.
//
+193
View File
@@ -0,0 +1,193 @@
// include/strata/kernels/rope_scaling.hpp - rope scaling: extending the context past the trained one.
//
// WHAT IT IS. The artifact's RoPE is trained over `orig_ctx` positions (the artifact ships no
// `rope.scaling` keys, so this is a pure runtime feature). Scaling re-uses positions past that by
// rescaling the rotation angles. Three types, the llama.cpp set:
//
// none freq_scale 1, no correction - today's behaviour, exactly
// linear Position Interpolation: angles shrunk by 1/factor - every position fits again, all
// frequencies compressed uniformly
// yarn YaRN: high-frequency pairs keep their trained angles (extrapolate), low-frequency
// pairs are interpolated, and the pairs in between blend - plus a magnitude correction
// `mscale` that keeps the attention temperature where training put it
//
// THE MATH IS GGML'S, TRANSCRIBED, NOT RE-DERIVED. `third_party/llama.cpp` is vendored and is the
// numerical contract (the same standing `native_rope.cu` declares): ramp, correction dims, the
// interpolation mix and the mscale formula below are line-for-line that source, with the MIT
// attribution at each helper. The one deliberate deviation is bookkeeping: ggml's device kernel
// speaks in even dim indices (`i0`) and halves them inside the ramp, while every Strata rope kernel
// speaks in PAIR indices - so `rope_yarn_ramp` here takes the pair index and compares it against
// `corr_dims` directly. Same numbers, one less division per call site.
//
// HOW IT REACHES THE ROPE. Two forms, one config:
//
// - the TABLE path (the default decode path, the indexer's pooled key) never changes its kernels:
// scaling is baked into the cos/sin contents by `build_rope_table`, which the parity test checks
// bit-exactly against a float64 reference;
// - the ANALYTIC paths (`--native-rope`, the native indexer, prefill) get the resolved constants
// as kernel arguments.
//
// **PROCESS CONSTANTS, SET ONCE BEFORE CAPTURE.** A CUDA graph bakes kernel ARGUMENTS at capture
// time, so the scaling must be fixed before `session_init` captures the layers - which is exactly
// the constraint the per-token values already respect by living in device memory (`st.pos_dev`).
// `rope_scaling_set()` is called once from the engine's startup, after CLI and GGUF defaults meet,
// before any graph exists. There is deliberately no way to change it mid-process: the K already in
// the cache is stored POST-RoPE, so a mid-run change would mix two scalings in one cache.
#pragma once
#include <cmath>
#if defined(__CUDACC__) || defined(__HIPCC__)
#define STRATA_ROPE_SCALING_HD __host__ __device__
#else
#define STRATA_ROPE_SCALING_HD
#endif
namespace strata::kernels {
enum class RopeScalingType { None, Linear, YaRN };
/// The float32 constants the ANALYTIC rotation kernels take (`rope_scaled_angle`'s arguments). The defaults are
/// the identity: freq_scale 1, no correction, magnitude 1 - with them the helper computes `cosf(theta) * 1.0f`
/// on `1.0f * theta`, which is exactly today's unscaled `cosf(theta)` bit for bit.
struct RopeKernelArgs {
float freq_scale = 1.0f;
float corr_low = 0.0f;
float corr_high = 0.0f;
float ext_factor = 0.0f;
float attn_factor = 1.0f;
};
/// The one rope configuration, as the CLI flag family and the (future) GGUF rope keys resolve to.
///
/// Default-constructed this is EXACTLY today's engine: freq_scale 1, mscale 1, no correction. That
/// identity is a regression gate, not a coincidence - the parity test compares the default's table
/// against the pre-scaling builder bit for bit.
struct RopeScaling {
RopeScalingType type = RopeScalingType::None;
double freq_base = 1e7; ///< `rope.freq_base` (the value pinned by `qsa_freq_base()`)
double factor = 1.0; ///< `--rope-scale`: how far past the trained context the run goes
double freq_scale_in = 0; ///< `--rope-freq-scale`: the raw ggml knob; 0 = 1/factor
double orig_ctx = 262144; ///< `--yarn-orig-ctx`: the context the model was trained over
double ext_factor = 0; ///< `--yarn-ext-factor`: correction depth; 0 = off, 1 = full YaRN
double attn_factor = 1.0; ///< `--yarn-attn-factor`: extra magnitude scale (ggml's attn_factor)
double beta_fast = 32.0; ///< `--yarn-beta-fast`
double beta_slow = 1.0; ///< `--yarn-beta-slow`
/// The angle shrink. ggml's `freq_scale`: 1 means "the trained angles", 1/factor squeezes
/// `factor` positions into the trained range.
double freq_scale() const {
if (type == RopeScalingType::None) return 1.0;
return freq_scale_in > 0 ? freq_scale_in : 1.0 / factor;
}
/// The magnitude correction. ggml's `rope_yarn` multiplies cos and sin by this; the log term
/// rides on `ext_factor != 0` (not on the type), exactly as that source has it. With the
/// default knobs YaRN factor F gives `1 + 0.1*ln(F)` - YaRN's temperature correction.
double mscale() const {
if (ext_factor == 0) return attn_factor;
return attn_factor * (1.0 + 0.1 * std::log(1.0 / freq_scale()));
}
/// The correction window, in PAIR indices: below `out[0]` pairs extrapolate (keep the trained
/// angles), above `out[1]` they interpolate, in between they blend. Transcribed from ggml's
/// `ggml_rope_yarn_corr_dims` (see the derivation comment at `rope_yarn_corr_dim` below).
void corr_dims(int n_rot, double out[2]) const {
const double start = std::floor(rope_yarn_corr_dim(n_rot, orig_ctx, beta_fast, freq_base));
const double end = std::ceil(rope_yarn_corr_dim(n_rot, orig_ctx, beta_slow, freq_base));
out[0] = start < 0 ? 0 : start;
out[1] = end > (double) (n_rot - 1) ? (double) (n_rot - 1) : end;
}
/// The analytic kernels' constants. `none` returns the identity constants WHATEVER the other knobs hold -
/// the table builder ignores them for `none` too, so the two rotation forms cannot disagree there, and an
/// unscaled run computes exactly what it computed before scaling existed. Linear/YaRN: ggml's `rope_yarn`
/// arguments (`attn_factor` is the raw knob; the helper applies the log term when correcting).
RopeKernelArgs kernel_args(int n_rot) const {
RopeKernelArgs a;
if (type == RopeScalingType::None) return a;
double cd[2];
corr_dims(n_rot, cd);
a.freq_scale = (float) freq_scale();
a.corr_low = (float) cd[0];
a.corr_high = (float) cd[1];
a.ext_factor = (float) ext_factor;
a.attn_factor = (float) attn_factor;
return a;
}
private:
/// ggml/src/ggml.c: "Apparently solving `n_rot = 2pi * x * base^((2 * max_pos_emb) / n_dims)`
/// for x, we get `corr_dim(n_rot) = n_dims * log(max_pos_emb / (n_rot * 2pi)) / (2 * log(base))`".
/// float64 rather than ggml's float32: the table path computes in float64 and is held to a
/// bit-exact float64 reference, so its inputs are float64 too.
static double rope_yarn_corr_dim(int n_dims, double n_ctx_orig, double n_rot, double base) {
return (double) n_dims * std::log(n_ctx_orig / (n_rot * 2.0 * 3.14159265358979323846)) /
(2.0 * std::log(base));
}
};
/// The ramp between extrapolation (1) and interpolation (0).
///
/// ggml/src/ggml-cuda/rope.cu `rope_yarn_ramp`: `1 - clamp(y, 0, 1)` with
/// `y = (i0 / 2 - low) / max(0.001, high - low)`. ggml passes the even dim index `i0`; this takes
/// the pair index (ggml's `i0/2`) so Strata's pair-indexed kernels don't re-derive the mapping. The
/// 0.001 floor on the denominator is ggml's, kept verbatim: `corr_dims` can produce low == high.
STRATA_ROPE_SCALING_HD inline float rope_yarn_ramp(float low, float high, int pair) {
const float y = ((float) pair - low) / (high - low > 0.001f ? high - low : 0.001f);
const float clamped = y < 0.0f ? 0.0f : (y > 1.0f ? 1.0f : y);
return 1.0f - clamped;
}
/// The scaled angle for one pair, float32, the ANALYTIC paths' contract (pinned ggml arithmetic).
///
/// Takes `theta_extrap` - the position times that pair's trained `inv_freq` - and returns the angle
/// to take cos/sin of, with ggml's mscale already folded in. `mscale_in` must be `attn_factor`
/// (ggml's kernels receive the raw knob and apply the log term here, where it is per-pair constant;
/// this helper keeps that shape so the native path reads against ggml line by line).
STRATA_ROPE_SCALING_HD inline void rope_scaled_angle(float theta_extrap, float freq_scale, float corr_low,
float corr_high, float ext_factor, float mscale_in,
int pair, float& cos_out, float& sin_out) {
// The interpolation, always applied; the correction mixed in on top (ggml-cuda/rope.cu rope_yarn).
float theta = freq_scale * theta_extrap;
float mscale = mscale_in;
if (ext_factor != 0.0f) {
const float ramp_mix = rope_yarn_ramp(corr_low, corr_high, pair) * ext_factor;
theta = theta * (1.0f - ramp_mix) + theta_extrap * ramp_mix;
// "Get n-d magnitude scaling corrected for interpolation" - the log term only when correcting.
mscale *= 1.0f + 0.1f * logf(1.0f / freq_scale);
}
cos_out = cosf(theta) * mscale;
sin_out = sinf(theta) * mscale;
}
/// The ONE validity rule for a resolved configuration, shared by the engine's startup check and every rotation
/// site that takes the struct, so a bad knob cannot pass one site and trip (or silently NaN) another. Returns
/// nullptr when the configuration is usable, else what is wrong. Every value is tested with `std::isfinite`
/// FIRST: a NaN passes every `<`/`<=` range comparison, which is how a `--rope-scale nan` would otherwise slip
/// through. `none` passes with any knob values - the knobs are inert there (the resolution resets them).
inline const char* rope_scaling_invalid(const RopeScaling& s) {
if (!std::isfinite(s.freq_base) || s.freq_base <= 1.0) return "the frequency base must be a finite value above 1";
if (s.type == RopeScalingType::None) return nullptr;
if (!std::isfinite(s.factor) || s.factor < 1.0) return "the scaling factor must be a finite value >= 1";
if (!std::isfinite(s.freq_scale_in) || s.freq_scale_in < 0.0)
return "the frequency scale must be finite and positive (0 = 1/factor)";
const double fs = s.freq_scale();
if (!std::isfinite(fs) || fs <= 0.0) return "the resolved frequency scale must be a finite positive value";
if (!std::isfinite(s.orig_ctx) || s.orig_ctx < 1.0) return "the original (trained) context must be finite and > 0";
if (!std::isfinite(s.ext_factor) || s.ext_factor < 0.0) return "the YaRN ext factor must be finite and >= 0";
if (!std::isfinite(s.attn_factor) || s.attn_factor <= 0.0) return "the attention factor must be finite and > 0";
if (!std::isfinite(s.beta_fast) || s.beta_fast <= 0.0 || !std::isfinite(s.beta_slow) || s.beta_slow <= 0.0)
return "the YaRN betas must be finite and > 0";
if (!std::isfinite(s.mscale()) || s.mscale() <= 0.0) return "the resolved magnitude correction must be finite and > 0";
return nullptr;
}
/// The process's one rope configuration. Set it ONCE at startup, after the CLI and the model file
/// have agreed on the values and BEFORE `session_init` (which builds the rope table and captures the
/// graphs); read it at table build time and when launching the analytic kernels. Plain storage, no
/// atomics: one writer on the startup thread, every reader after it.
void rope_scaling_set(const RopeScaling& scaling);
const RopeScaling& rope_scaling();
} // namespace strata::kernels
+18
View File
@@ -42,6 +42,24 @@ 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);
// ---- 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.
//
// The scratch `coupled_draft_sample` needs for `nv` logits (0: too wide for the split merge, coupled cannot run).
size_t coupled_draft_scratch_bytes(int nv);
// A round's inputs: `*params` <- `*mapped_params`, ring[cap - h, cap) <- mapped_hist[cap - h, cap) with h =
// coupled_hist_len(penalty_last_n, cap). `mapped_*` are mapped host memory.
void coupled_draft_stage(const SamplerParams* mapped_params, const int32_t* mapped_hist, SamplerParams* params,
int32_t* ring, int cap, void* stream);
// Draft j of the chain from ONE row of `nv` logits (modified in place: the penalties): the target's chain with
// `*params`, the penalty window ring[cap + j - h, cap + j), Philox counter coupled_draft_counter(step_rec[0]).
// `sub_to_id` / `id_to_sub` map the draft head's subset to token ids and back (null: the whole vocabulary,
// `id_vocab` ids). Writes the token id to *out_id, its probability under the final distribution to *out_prob, and
// the id to ring[cap + j].
void coupled_draft_sample(float* logits, int nv, const int32_t* sub_to_id, const int32_t* id_to_sub, int id_vocab,
const SamplerParams* params, int32_t* ring, int cap, int j, const int32_t* step_rec,
void* scratch, int32_t* out_id, float* out_prob, void* stream);
// The penalty-history rows of a verify window, on the host: row t of `out` (T rows of `h` slots) is the last `h`
// tokens of `tail[0..n_tail)` followed by `window[0..t]`, most recent LAST, -1 in the unused front slots.
// `tail` is what the state consumed before the window, `window[0]` the fed-back token and `window[1..]` the
@@ -38,6 +38,8 @@ void gdn_step_norm_multi(float* state, const float* h, int conv_channels, const
/// Spin until *flag >= value (a mapped host flag). The value is fixed at capture, so several rings can be
/// outstanding at once (the split verify window keeps two).
void wait_flag_ge(const uint32_t* flag, uint32_t value, void* stream);
/// the GPU's %globaltimer (ns) into buf[i] (a one-thread kernel: the verify window's stage profiler).
void gpu_stamp(unsigned long long* buf, int i, void* stream);
// ---- perf-review E-6: a layer whose routed experts are all in VRAM needs nothing from the host
/// One group's plan, built on the device when every routed expert of its n*k entries is resident: the host pool's
+2
View File
@@ -6,6 +6,7 @@
// rounded to BF16, which is also what llama.cpp's batched CUDA path does. Tensor-core GEMM through cuBLAS.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
@@ -50,6 +51,7 @@ private:
int64_t scratch_elems_ = 0;
void* workspace_ = nullptr;
bool external_ = false;
void* hipblaslt_state_ = nullptr;
};
+17 -2
View File
@@ -6,6 +6,7 @@
#pragma once
#include "strata/kernels/kv_stream.hpp"
#include "strata/kernels/rope_scaling.hpp"
#include <cstdint>
@@ -14,6 +15,15 @@ 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);
/// 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);
/// 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);
/// 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);
/// lo16[t, k] = bf16(silu(lo[t, k] / hc))
void gr_silu(const float* lo, uint16_t* lo16, int64_t T, void* stream);
/// mixed[t, d] = mean_c xn[t, c, d] * sigmoid(gated[t, c, d]); FP32, BF16 and FP16 (either image may be null).
@@ -44,6 +54,9 @@ void blob_dequant(const uint8_t* blob, uint16_t* gu16, uint16_t* down16, void* s
void swiglu_interleaved(const float* gu, uint16_t* h16, int64_t n, void* stream);
/// h16[n, r] = fp16(silu(g[n, r]) * u[n, r]) (the shared expert, gate and up separate, width 640)
void swiglu_pair(const float* g, const float* u, uint16_t* h16, int64_t n, void* stream);
/// dst[i] = src[i] for n int32s, as a kernel: either side may be mapped host memory, and the copy never waits
/// behind the copy engine's queue (the prompt path's grouping tables, while the expert stream fills it).
void copy_i32(int32_t* dst, const int32_t* src, int64_t n, void* stream);
/// Gather rows: dst16[i, :] = x16[src[i], :] (n rows of `width` BF16).
void gather_rows16(const uint16_t* x16, const int32_t* src, uint16_t* dst16, int64_t n, int64_t width, void* stream);
/// bo[t, :] = shared[t, :] * sigmoid(sg[t]) + sum_k w[t, k] * D[slot[t, k], :]
@@ -53,8 +66,10 @@ void moe_combine(const float* D, const int32_t* slot, const float* w, const floa
// ---- QSA helpers
/// In place: x[r, :] = x[r, :] * rsqrt(mean x^2 + eps) * w over rows of `cols` (row stride `ld`).
void rms_rows(float* x, const float* w, int64_t rows, int64_t cols, int64_t ld, float eps, void* stream);
/// NEOX rotary on the first 64 dims of each head: x [T, heads, dim] at positions pos0 + t.
void rope(float* x, int64_t T, int64_t heads, int64_t dim, int64_t ld, int64_t pos0, float freq_base, void* stream);
/// NEOX rotary on the first 64 dims of each head: x [T, heads, dim] at positions pos0 + t. The rope
/// scaling (rope_scaling.hpp) rides in as the process config - none is today's arithmetic exactly.
void rope(float* x, int64_t T, int64_t heads, int64_t dim, int64_t ld, int64_t pos0,
const strata::kernels::RopeScaling& scaling, void* stream);
/// q_full [T, 24, 512] (q | gate per head) -> q [T, 24, 256]
void split_q(const float* q_full, float* q, int64_t T, void* stream);
/// attn[t, h, d] *= sigmoid(q_full[t, h, 256 + d]) -> out16 (fp16 bits: the o-projection is quantized)
+11
View File
@@ -34,8 +34,19 @@ struct PrefillStats {
double ms_ple = 0;
};
} // namespace strata::prefill
namespace strata::core { class MtpDrafter; }
namespace strata::prefill {
class Prefill {
public:
/// E-9: the draft layer's K/V for prompt cells [cell0, cell0 + n) from their final residual rows `R_rows`
/// (device) and `next_tokens` (host: the token at cell+1), in batches through this path's GEMMs and its idle
/// scratch - call it from on_chunk. false with `err` empty: not applicable here (a ring or hybrid K/V, another
/// device, too little scratch; STRATA_MTP_BATCH=0), the caller runs the drafter's own pass. Not bit-identical to
/// that pass (FP16 GEMMs instead of Q8_1 activations): the drafts may differ, never the target's tokens' logits.
bool draft_kv(core::MtpDrafter& mtp, const float* R_rows, const int32_t* next_tokens, int64_t n, int64_t cell0,
std::string& err);
Prefill();
~Prefill();
Prefill(const Prefill&) = delete;
+67 -10
View File
@@ -38,7 +38,8 @@ class ChatTemplate:
env = ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True, extensions=["jinja2.ext.loopcontrols"])
env.filters["tojson"] = tojson
env.globals["raise_exception"] = raise_exception
self.template = env.from_string(Path(path).read_text(encoding="utf-8"))
self.source = Path(path).read_text(encoding="utf-8")
self.template = env.from_string(self.source)
def render(self, messages: list[dict], tools: list[dict] | None = None, add_generation_prompt: bool = True,
**kwargs) -> str:
@@ -144,7 +145,7 @@ def openai_to_messages(req: dict) -> tuple[list[dict], list[dict] | None, dict]:
role = m.get("role")
if role == "developer":
role = "system"
out = {"role": role, "content": _parts_of(m.get("content")) if role == "user" else _text_of(m.get("content"))}
out = {"role": role, "content": _parts_of(m.get("content")) if role in ("user", "tool", "assistant") else _text_of(m.get("content"))}
if m.get("reasoning_content"):
out["reasoning_content"] = m["reasoning_content"]
if m.get("tool_calls"):
@@ -241,6 +242,61 @@ CALL_START = "<tool_call>"
CALL_END = "</tool_call>"
PARAM_END = "</parameter>"
FUNC_END = "</function>"
def param_end(text: str, final: bool = False) -> int:
"""Where a parameter value in `text` ends: the first `</parameter>` followed (after whitespace) by the next
`<parameter=` or `</function>` - the same text inside a value (a file that documents the call format, #210) is
part of the value. -1: none yet; -2: a candidate whose follower has not arrived (streaming; `final` accepts it)."""
at = text.find(PARAM_END)
while at >= 0:
after = text[at + len(PARAM_END):].lstrip()
if after.startswith(("<parameter=", FUNC_END)):
return at
if not after or "<parameter=".startswith(after) or FUNC_END.startswith(after):
return at if final else -2
at = text.find(PARAM_END, at + 1)
return -1
def call_end(text: str) -> int:
"""Where a tool call's body ends (#210): the `</tool_call>` after the call's own `</function>`, found by walking
its parameters with param_end, so a value may contain either tag. -1: not complete yet. A body that is not in
the call format ends at the first `</tool_call>`, as before."""
s = text.lstrip()
pos = len(text) - len(s)
if not s.startswith("<function="):
return text.find(CALL_END) if not "<function=".startswith(s) else -1
gt = text.find(">", pos)
if gt < 0:
return -1
pos = gt + 1
while True:
rest = text[pos:]
s = rest.lstrip()
pos += len(rest) - len(s)
if s.startswith("<parameter="):
gt = text.find(">", pos)
if gt < 0:
return -1
end = param_end(text[gt + 1:])
if end < 0:
return -1
pos = gt + 1 + end + len(PARAM_END)
elif s.startswith(FUNC_END):
rest = text[pos + len(FUNC_END):]
s = rest.lstrip()
if s.startswith(CALL_END):
return pos + len(FUNC_END) + len(rest) - len(s)
return -1 if CALL_END.startswith(s) else text.find(CALL_END, pos)
elif not s or "<parameter=".startswith(s) or FUNC_END.startswith(s):
return -1
else:
return text.find(CALL_END, pos)
def parse_tool_call(body: str, schema: dict | None = None) -> ToolCall:
"""`<function=NAME>\\n<parameter=P>\\nVALUE\\n</parameter>...</function>` -> ToolCall. Values are JSON-decoded
when the tool's schema says the parameter is not a string (or, without a schema, when they parse as JSON
@@ -256,9 +312,9 @@ def parse_tool_call(body: str, schema: dict | None = None) -> ToolCall:
rest = rest[rest.index("<parameter=") + len("<parameter="):]
pname = rest[:rest.index(">")]
rest = rest[rest.index(">") + 1:]
end = rest.find("</parameter>")
end = param_end(rest, final=True)
value = rest[:end] if end >= 0 else rest
rest = rest[end + len("</parameter>"):] if end >= 0 else ""
rest = rest[end + len(PARAM_END):] if end >= 0 else ""
if value.startswith("\n"):
value = value[1:]
if value.endswith("\n"):
@@ -350,16 +406,17 @@ class OutputParser:
self.sp += 1
rest = rest[1:]
self.sval_started = True
end = rest.find("</parameter>")
end = param_end(rest)
if end >= 0:
value = rest[:end]
if value.endswith("\n"):
value = value[:-1]
args(json.dumps(value)[1:-1] + '"')
self.sp += end + len("</parameter>")
self.sp += end + len(PARAM_END)
self.ss = "between"
continue
safe = len(rest) - self._hold(rest, ("</parameter>",))
# an undecided </parameter> (-2) is held from its start, like a tag still arriving
safe = rest.find(PARAM_END) if end == -2 else len(rest) - self._hold(rest, (PARAM_END,))
if safe > 0 and rest[safe - 1] == "\n": # may be the trailing newline before </parameter>
safe -= 1
if safe > 0:
@@ -367,7 +424,7 @@ class OutputParser:
self.sp += safe
return out
elif self.ss == "raw":
end = rest.find("</parameter>")
end = param_end(rest)
if end < 0:
return out
value = rest[:end]
@@ -380,7 +437,7 @@ class OutputParser:
except ValueError:
v = value
args(json.dumps(v, ensure_ascii=False))
self.sp += end + len("</parameter>")
self.sp += end + len(PARAM_END)
self.ss = "between"
else:
return out
@@ -448,7 +505,7 @@ class OutputParser:
self.buf = self.buf[i + len(CALL_START):]
self.state = "call"
else:
i = self.buf.find(CALL_END)
i = call_end(self.buf)
if self.stream_tools:
if i >= 0:
whole, self.buf = self.buf, self.buf[:i] # scan only the body
+3
View File
@@ -31,6 +31,8 @@ import time
import urllib.error
import urllib.request
from serve.winjob import contain
PROTOCOL = "2025-06-18" # the MCP revision Strata asks for; the server's answer is used as given
DEFAULTS = {"timeout_s": 60.0, "max_result_chars": 20000, "max_rounds": 8, "start_timeout_s": 120.0}
@@ -105,6 +107,7 @@ class StdioTransport:
env=env, **extra)
except OSError as e:
raise McpError(f"could not start {command!r}: {e}") from None
contain(self.proc) # ends with the server, however it ends (Windows)
threading.Thread(target=self._read, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
+438 -38
View File
@@ -4,7 +4,7 @@
python -m serve.server --engine strata --config strata.json --port 8080 (the real engine, resident)
Endpoints: POST /v1/chat/completions (OpenAI, stream and non-stream), POST /v1/messages (Anthropic, stream and
non-stream), GET /v1/models, GET /health, GET /mcp. One sequence at a time behind a FIFO (plan: one resident sequence).
non-stream), GET /v1/models, GET /models, GET /props, GET /slots, GET /health, GET /mcp. One sequence at a time behind a FIFO (plan: one resident sequence).
Tools from MCP servers (serve/mcp.py, `"mcp_servers"` in the config or --mcp-config) are offered only to requests that
ask for them with `"strata_mcp": true` - the web app does; other clients see exactly the API they always saw.
Images (optional, when the config has a "vision" entry): OpenAI image_url parts and Anthropic image blocks (base64
@@ -24,10 +24,13 @@ import argparse
import collections
import base64
import hashlib
import hmac
import codecs
import ctypes
import json
import os
import queue
import signal
import subprocess
import sys
import tempfile
@@ -38,6 +41,7 @@ import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Iterator, Protocol
from urllib.parse import parse_qs, urlsplit
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
@@ -45,10 +49,16 @@ sys.path.insert(0, str(ROOT)) # run as a script (run-<model>.bat) as well as a
from serve.frontend import (ChatTemplate, Event, OutputParser, anthropic_to_messages, # noqa: E402
images_of, openai_to_messages)
from serve.mcp import McpCancelled, hub_from_config # noqa: E402
from serve.winjob import contain # noqa: E402
IM_END = "<|im_end|>"
IMAGE_PAD = "<|image_pad|>"
VISION_START = "<|vision_start|>"
CTX_SLACK = 8 # `strata --serve` rejects prompt + max_new + 8 > context: keep the same margin here
# The live tok/s is a rate over a window, not a mean since the first token: a mean reads ~1/elapsed at the first
# token (the Monitor showed five-digit numbers) and then undershoots for the first second of every answer.
RATE_WINDOW_S = 2.0
RATE_MIN_SPAN_S = 0.25 # younger than this there is no rate yet: the mean so far, with the span floored here
# ------------------------------------------------------------------------------------------------ engines
@@ -87,6 +97,11 @@ class EngineDied(RuntimeError):
"""The engine process ended in the middle of a request (issue #27: on Linux, the out-of-memory killer)."""
class GpuBusy(RuntimeError):
"""The model is unloaded and the GPU has less free VRAM than min_free_vram_mib: something else (a game, another
model server) is using it, so the engine is not started into the little that is left."""
def narrate_start(log_path: str, offset: int, args: list, done: threading.Event, heartbeat=20.0) -> None:
"""While the engine starts, say in the server window what it is doing, from its log: the start reads tens of GB
into RAM and locks part of it for the GPU, and on many PCs everything is slow or frozen for a minute or more -
@@ -150,6 +165,8 @@ class StrataEngine:
def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str | None = None,
env: dict | None = None):
self.spawn = (exe, list(args), cwd, log, env) # to start it again after it died (issue #27)
paths = {k: v for k, v in zip(args, args[1:]) if k in ("--native", "--pack")}
self.model_path = paths.get("--native") or paths.get("--pack", "pack/full")
self.log_path = log
self.log = open(log, "a", encoding="utf-8") if log else subprocess.DEVNULL
loading = threading.Event() # set once READY: the narrator below stops
@@ -158,10 +175,13 @@ class StrataEngine:
daemon=True).start()
self.proc = subprocess.Popen([exe, "--serve", *args], cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=self.log, text=True, encoding="utf-8", bufsize=1, env=env)
contain(self.proc) # ends with the server, however it ends (Windows)
self.max_context = 0
self.unloaded = False # stopped on purpose (idle unload, POST /unload), not crashed
self.can_stop = False # the engine honours a STOP line mid-request (READY <ctx> stop)
self.last = {}
self.info = {} # INFO key=value facts (engine 0.1.8+): kv, expert slots, ... (Monitor tab)
self.prefill_tok_s_mean = None
self.progress = None # (read, total) prompt tokens while a prompt is read, from PP lines
try: # a ready-made engine's BUILD.json says its version
self.info["version"] = json.loads((Path(exe).parent / "BUILD.json").read_text()).get("version")
@@ -208,6 +228,11 @@ class StrataEngine:
if "issue #29" in line:
return ("The engine stopped itself because it had stopped making progress - a hang it caught. Its log "
"line: " + line.strip() + " - please report it at github.com/Niko1221/Strata/issues.")
rc = self.proc.poll()
last = next((x.strip() for x in reversed(tail.splitlines()) if x.strip().startswith(("strata", "ERR"))), "")
if rc is not None and rc >= 0 and last: # it ended by itself: its own last words say why (#215)
return (f"The engine exited (code {rc}). Its last log line: {last} - if that does not explain it, please "
"report it at github.com/Niko1221/Strata/issues with the log.")
return ("The usual cause is running out of RAM: Linux then ends the biggest program (check: sudo dmesg | "
"grep -i -E 'killed process|out of memory'); Windows slows down instead. Close other programs or use a "
"smaller model (Q2_0 / IQ2_XS).")
@@ -221,6 +246,25 @@ class StrataEngine:
except subprocess.TimeoutExpired:
return None
def unload(self):
"""Stop the engine process so its VRAM and RAM go back to the system (idle unload, POST /unload); the next
request starts it again with restart(). Only between requests: the caller holds the service's fifo."""
try:
try: # QUIT first, as close() does: the engine frees its memory
self.proc.stdin.write("QUIT\n")
self.proc.stdin.flush()
self.proc.wait(timeout=20)
except (OSError, ValueError, subprocess.TimeoutExpired):
self.proc.terminate()
self.proc.wait(timeout=20)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc.wait(timeout=20)
except OSError:
pass
self.ended = True
self.unloaded = True
def restart(self):
"""Start the engine again (the same command) after it died; the new process has its own line queue."""
try:
@@ -300,8 +344,10 @@ class StrataEngine:
the HTTP layer turns it into an SSE comment, which keeps clients' watchdogs calm and notices a client that
has gone. A consumer that stops early (or `cancel`) makes the engine STOP, so it does not run to max_new."""
self.progress = None
head = f"GENI {int(max_new)}{self.projection_key(sampling or {})} {embeddings}" if embeddings else \
f"GEN {int(max_new)}{self.sampling_keys(sampling or {}) if not embeddings else ''}"
self.prefill_tok_s_mean = None
# an image request takes the same sampling keys as text (#75: it used to decode greedily whatever was asked)
head = f"GENI {int(max_new)}{self.sampling_keys(sampling or {})} {embeddings}" if embeddings else \
f"GEN {int(max_new)}{self.sampling_keys(sampling or {})}"
try:
self.proc.stdin.write(f"{head} {','.join(str(int(t)) for t in ids)}\n")
self.proc.stdin.flush()
@@ -328,6 +374,7 @@ class StrataEngine:
f = line.split()
if len(f) >= 3 and f[1].isdigit() and f[2].isdigit():
self.progress = (int(f[1]), int(f[2])) # prompt progress, one per chunk: also a heartbeat (the
self.prefill_tok_s_mean = float(f[4]) if len(f) >= 5 else None
if cancel.is_set(): # lines reset the 10 s wait, so without this a long prompt
return # would send no keep-alives at all)
yield None
@@ -377,13 +424,37 @@ class Vision:
if cfg.get("max_tokens"):
args += ["--max-tokens", str(cfg["max_tokens"])]
self.dir = Path(tempfile.mkdtemp(prefix="strata-vision-"))
self.spawn = (args, log, env) # to start it again after an unload
self.stopped = False
self._start()
self.lock = threading.Lock()
self.cache: dict[str, tuple[Path, int]] = {}
def _start(self):
args, log, env = self.spawn
self.proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=log or subprocess.DEVNULL,
text=True, encoding="utf-8", bufsize=1, env=env)
contain(self.proc)
line = self.proc.stdout.readline()
if not line.startswith("READY"):
raise RuntimeError("the vision encoder did not start: " + line.strip())
self.lock = threading.Lock()
self.cache: dict[str, tuple[Path, int]] = {}
self.stopped = False
def alive(self) -> bool:
return not self.stopped and self.proc.poll() is None
def unload(self):
"""Stop the encoder process (its VRAM or RAM goes back); the encoded images stay cached on disk."""
self.close()
self.stopped = True
def restart(self):
"""Start the encoder again after an unload (or if it died); the cache of encoded images is kept."""
try:
self.proc.kill()
except OSError:
pass
self._start()
@staticmethod
def load(source: str) -> bytes:
@@ -480,9 +551,13 @@ def child_env(cfg: dict) -> dict:
"""The engine's environment: the CUDA libraries setup installed (pip's nvidia packages, or the toolkit that
compiled it) first on the library search path."""
env = dict(os.environ)
if gpu_list(cfg): # issue #51: the GPU(s) to run on, numbered as nvidia-smi does; CUDA's
if gpu_list(cfg) and cfg.get("backend") == "hip": # AMD: numbered as HIP numbers them (setup's KFD order)
env["HIP_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpu_list(cfg))
elif gpu_list(cfg): # issue #51: the GPU(s) to run on, numbered as nvidia-smi does; CUDA's
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID" # own default order (fastest first) can number the cards otherwise
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpu_list(cfg))
for k, v in (cfg.get("env") or {}).items(): # engine settings the config carries (AMD: the GEMM tuning table)
env[str(k)] = str(v)
dirs = [d for d in cfg.get("lib_dirs") or [] if Path(d).is_dir()]
if dirs:
var = "PATH" if os.name == "nt" else "LD_LIBRARY_PATH"
@@ -550,15 +625,124 @@ class Service:
self.embeddings = threading.local() # the current request's image embeddings file (GENI)
self.api_key = "" # when set, /v1/* needs it (Bearer or x-api-key)
self.status = {"busy": False, "queued": 0} # GET /status: what the model is doing right now
self.rate = collections.deque(maxlen=32) # (time, generated) samples for the live tok/s window
self.history = collections.deque(maxlen=500) # the last finished requests, newest last (GET /metrics)
# since the server started (the Monitor's totals, issue #35)
self.totals = {"since": time.time(), "requests": 0, "prompt_tokens": 0, "reused": 0, "output_tokens": 0,
"prompt_ms": 0.0, "decode_ms": 0.0}
self.last_timings = None # the last finished request's, llama.cpp's names (/v1/status)
self.last_request_at = None # when a request last started or finished
self.started_at = time.time()
self.status_lock = threading.Lock()
self.mcp = None # serve/mcp.py's McpHub when MCP servers are configured
# sharing the GPU with other programs (all off by default): unload the engine after this many idle seconds,
# only start it again when this much VRAM is free, and run this command first (e.g. to unload another
# server's model); the next request after an unload starts the engine again
self.idle_unload_s = 0
self.min_free_vram_mib = 0
self.before_load = None
self.stop_ids = set(tokenizer.encode(IM_END, parse_special=True) +
tokenizer.encode("<|endoftext|>", parse_special=True))
def loaded(self) -> bool:
return not hasattr(self.engine, "alive") or self.engine.alive()
def _vision_down(self) -> bool:
return self.vision is not None and hasattr(self.vision, "alive") and not self.vision.alive()
def free_vram_mib(self) -> int | None:
"""Free VRAM on the engine's (first) GPU, from NVML; None when it can't be read (then nothing is refused)."""
try:
from serve.telemetry import _Nvml
nv = _Nvml(int(getattr(self, "gpu_index", 0) or 0))
if not nv.ok():
return None
m = nv.Mem()
if nv.lib.nvmlDeviceGetMemoryInfo(nv.dev, ctypes.byref(m)) != 0:
return None
return int(m.free >> 20)
except Exception:
return None
def ensure_loaded(self):
"""Start the engine if it is not running (unloaded, or it died - issue #27), after the before_load hook and
the free-VRAM check. The caller holds self.fifo."""
if self.loaded() and not self._vision_down():
return
if self.before_load:
cmd = self.before_load
print(f"[strata] before loading: {cmd if isinstance(cmd, str) else ' '.join(map(str, cmd))}", flush=True)
try:
subprocess.run(cmd, shell=isinstance(cmd, str), timeout=120, stdin=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError) as e:
print(f"[strata] the before_load command failed ({e}); loading anyway", flush=True)
if self.min_free_vram_mib:
free = self.free_vram_mib()
deadline = time.time() + 15 # memory another process just gave back can take a moment
while free is not None and free < self.min_free_vram_mib and time.time() < deadline:
time.sleep(0.5)
free = self.free_vram_mib()
if free is not None and free < self.min_free_vram_mib:
raise GpuBusy(f"the GPU is in use by another program: {free} MiB of VRAM free, the model needs "
f"{self.min_free_vram_mib} (min_free_vram_mib) - it stays unloaded until that is free")
if self._vision_down(): # first, as at a start: a GPU encoder takes its VRAM before
print("[strata] starting the vision encoder again ...", flush=True) # the engine sizes its cache
self.vision.restart()
if self.loaded():
return
if getattr(self.engine, "unloaded", False):
print("[strata] loading the model again (it was unloaded) ...", flush=True)
else:
code = self.engine.exit_code() if hasattr(self.engine, "exit_code") else None
print(f"[strata] the engine had stopped (exit code {code}); starting it again "
"(a minute or two) ...", flush=True)
self.engine.restart()
print("[strata] the engine is running again", flush=True)
def load(self):
"""POST /load and every generation request: start the engine now if it is unloaded (raises GpuBusy)."""
# a request is on its way: the idle thread must not unload between this and the request's own start
self.last_request_at = time.time()
if self.loaded() and not self._vision_down():
return
with self.fifo:
self.ensure_loaded()
def unload(self, idle_for: float | None = None) -> str:
"""Stop the engine between requests: "unloaded", "not loaded", "busy" (a request is running or waiting, or
with idle_for: one ran more recently than that) or "unsupported"."""
if not hasattr(self.engine, "unload"):
return "unsupported"
if not self.fifo.acquire(blocking=False):
return "busy"
try:
if not self.engine.alive():
return "not loaded"
with self.status_lock:
if self.status.get("busy") or self.status.get("queued"):
return "busy"
if idle_for is not None and time.time() - (self.last_request_at or self.started_at) < idle_for:
return "busy"
self.engine.unload()
if self.vision is not None and hasattr(self.vision, "unload"):
self.vision.unload()
print(f"[strata] model unloaded{f' after {idle_for:.0f} s idle' if idle_for else ''}; "
"the next request loads it again", flush=True)
return "unloaded"
finally:
self.fifo.release()
def start_idle_unload(self):
if not self.idle_unload_s or not hasattr(self.engine, "unload"):
return
print(f"[strata] the model unloads after {self.idle_unload_s} s without requests", flush=True)
def loop():
while True:
time.sleep(max(1.0, min(30.0, self.idle_unload_s / 4)))
self.unload(idle_for=self.idle_unload_s)
threading.Thread(target=loop, daemon=True).start()
def set_shared(self, defaults) -> dict:
"""The Chat settings every client gets for what it leaves out; {} / None = clients use their own again."""
self.shared = clean_shared_defaults(defaults)
@@ -598,17 +782,39 @@ class Service:
"""The hardware sampler behind GET /metrics (serve/telemetry.py), recording this server's tok/s too."""
if getattr(self, "telemetry", None) is None:
from serve.telemetry import Telemetry
self.telemetry = Telemetry(extra=lambda: {"tok_s": self._tok_s()},
self.telemetry = Telemetry(extra=lambda: {"tok_s": self._tok_s(), "tok_s_mean": self._tok_s_mean(),
"prefill_tok_s_mean": self._prefill_tok_s_mean()},
gpu_index=int(getattr(self, "gpu_index", 0) or 0),
gpu_indices=getattr(self, "gpu_indices", None))
def _tok_s(self):
"""tok/s over the last RATE_WINDOW_S seconds. Returns 0.0 while nothing is generating."""
with self.status_lock:
s = dict(self.status)
rate = list(self.rate)
if not s.get("busy") or not s.get("first_token"):
return 0.0
now = time.time()
newest = rate[-1] if rate else None
oldest = next(((t, g) for t, g in rate if now - t <= RATE_WINDOW_S), None)
if newest and oldest and newest[0] - oldest[0] >= RATE_MIN_SPAN_S:
return max(0.0, (newest[1] - oldest[1]) / (newest[0] - oldest[0]))
return s["generated"] / max(RATE_MIN_SPAN_S, now - s["first_token"])
def _tok_s_mean(self):
"""The whole-request mean since the first token (the old formula), kept so the two can be compared."""
with self.status_lock:
s = dict(self.status)
if not s.get("busy") or not s.get("first_token"):
return 0.0
return s["generated"] / max(1e-6, time.time() - s["first_token"])
def _prefill_tok_s_mean(self):
"""Engine-reported mean over newly read tokens, excluding the cached prefix."""
with self.status_lock:
reading = self.status.get("busy") and self.status.get("first_token") is None
return getattr(self.engine, "prefill_tok_s_mean", None) if reading else 0.0
def metrics(self, all_requests=False) -> dict:
"""GET /metrics: what the Monitor tab shows - the engine's facts, what it is doing, the last requests, and
the hardware (with a minute of history per series)."""
@@ -622,6 +828,8 @@ class Service:
state = "reading"
elif s.get("busy"):
state = "generating"
elif not self.loaded():
state = "unloaded"
else:
state = "idle"
live = {"state": state, "queued": s.get("queued", 0), "phase": s.get("phase") if s.get("busy") else None,
@@ -629,7 +837,10 @@ class Service:
"prompt_read": None, "prompt_total": None, "generated": s.get("generated") if s.get("busy") else None,
"max_tokens": s.get("max_tokens") if s.get("busy") else None,
"elapsed_s": round(now - s["started"], 1) if s.get("busy") and s.get("started") else None,
"tok_s": round(self._tok_s(), 1) if state == "generating" else None}
"tok_s": round(self._tok_s(), 1) if state == "generating" else None,
"tok_s_mean": round(self._tok_s_mean(), 1) if state == "generating" else None,
"prefill_tok_s_mean": getattr(self.engine, "prefill_tok_s_mean", None) if s.get("busy") else None,
"tok_s_window_s": RATE_WINDOW_S if state == "generating" else None}
if state == "reading" and progress:
live["prompt_read"], live["prompt_total"] = progress
engine = {"model": self.model, "max_context": self.engine.max_context, "images": self.vision is not None,
@@ -640,6 +851,41 @@ class Service:
"hardware_static":
tel["static"], "history": tel["history"], "time": now}
def v1_status(self) -> dict:
"""GET /v1/status: what this server is and does, for a client that would rather ask than guess (a front-end
that polls its OpenAI-compatible server's status, collabosm's for one): the model and its window, images,
the APIs, what is running, and the last request's timings in llama.cpp's names. /metrics has the rest."""
with self.status_lock:
s, totals = dict(self.status), dict(self.totals)
last_t, last_at = (dict(self.last_timings) if self.last_timings else None), self.last_request_at
tel = self.telemetry.snapshot() if getattr(self, "telemetry", None) else {"now": {}, "static": {}}
hw, static = tel.get("now") or {}, tel.get("static") or {}
def scaled(v, unit, digits=0):
return round(v / unit, digits) if isinstance(v, (int, float)) else None
busy, ctx = bool(s.get("busy")), self.engine.max_context
images = self.vision is not None
return {
"service": "strata", "model": self.model,
"engine": (getattr(self.engine, "info", {}) or {}).get("version"),
"started": int(self.started_at), "uptime_s": int(time.time() - self.started_at),
"cache_max_tokens": ctx,
"context": {"native": ctx, "max_positions": ctx},
"concurrency": {"serving": 1, "requested": 1}, # one request at a time; more wait their turn
"dialects": ["/v1/chat/completions", "/v1/messages"],
"vision": {"enabled": images, "available": images, "error": None},
"activity": {"requests": totals["requests"] + int(busy), "in_flight": int(busy) + int(s.get("queued") or 0),
"last_request_at": int(last_at) if last_at else None},
"last_timings": last_t,
"machine": {
"at": int(time.time()),
"gpu": {"name": static.get("gpu_name"), "used_mib": scaled(hw.get("gpu_mem_used"), 2 ** 20),
"total_mib": scaled(hw.get("gpu_mem_total"), 2 ** 20), "util_pct": hw.get("gpu_util"),
"temp_c": hw.get("gpu_temp"), "power_w": hw.get("gpu_power")} if static.get("gpu_name") else None,
"ram": {"used_gib": scaled(hw.get("ram_used"), 2 ** 30, 1),
"total_gib": scaled(hw.get("ram_total"), 2 ** 30, 1)} if hw.get("ram_total") else None}}
def prepare(self, messages, tools, kwargs, max_new=None):
"""-> (ids, thinking, max_new). An unset or non-positive max_new (some clients send -1) means "unlimited":
the rest of the context."""
@@ -652,17 +898,24 @@ class Service:
raise ValueError("this server was started without the vision encoder (run setup again and choose "
"'vision'), so it cannot read images")
pad = self.tok.encode(IMAGE_PAD, parse_special=True)[0]
start = self.tok.encode(VISION_START, parse_special=True)[0]
# Encode only while the engine is idle: the engine and the image encoder (a separate process) must not
# run on the GPU at the same time - an encode during a running request left that request stuck at
# "reading the prompt" with CPU and GPU busy, for good (reproduced). So encoding takes its turn in the
# same FIFO as the requests.
with self.fifo:
encoded = [self.vision.encode(src) for src in images]
# one <|image_pad|> per image -> one per image token. Only the markers the template writes for an image
# (right after <|vision_start|>) are images: the same text inside a message (an agent reading these docs,
# #150) is kept as plain text, or it took an image's place and the counts no longer matched.
literal = self.tok.encode(IMAGE_PAD, parse_special=False)
out, k = [], 0
for t in ids: # one <|image_pad|> per image -> one per image token
if t == pad and k < len(encoded):
for j, t in enumerate(ids):
if t == pad and j > 0 and ids[j - 1] == start and k < len(encoded):
out += [pad] * encoded[k][1]
k += 1
elif t == pad:
out += literal
else:
out.append(t)
if k != len(encoded):
@@ -692,6 +945,7 @@ class Service:
s["generated"] = n
if s.get("first_token") is None:
s["first_token"] = time.time()
self.rate.append((time.time(), n)) # the live rate's window over the last RATE_WINDOW_S
for ev in evs:
if ev.kind == "reasoning":
s["phase"] = "thinking"
@@ -729,24 +983,26 @@ class Service:
sampling = {**defaults, **req_values}
parser = OutputParser(thinking=thinking, tools=tools, stream_tools=True)
detok, n, finish = Detokenizer(self.tok), 0, "length"
timings, before = None, None # this request's timings; the engine's `last` before it
raw_ids = [] # every generated id (STRATA_DEBUG: dump raw model text)
emb = getattr(self.embeddings, "path", None)
# Identity token: only a DONE line replaces engine.last, so a request that died, errored or was
# disconnected must not have the PREVIOUS request's decode figures recorded as its own.
engine_last0 = getattr(self.engine, "last", None)
with self.status_lock:
self.status["queued"] += 1
try:
with self.fifo:
with self.status_lock:
self.status["queued"] -= 1
if hasattr(self.engine, "alive") and not self.engine.alive():
# issue #27: it died in an earlier request - start it again instead of failing every request
code = self.engine.exit_code() if hasattr(self.engine, "exit_code") else None
print(f"[strata] the engine had stopped (exit code {code}); starting it again "
"(a minute or two) ...", flush=True)
self.engine.restart()
print("[strata] the engine is running again", flush=True)
# issue #27: it died in an earlier request (or was unloaded) - start it again instead of failing
self.ensure_loaded()
with self.status_lock:
self.status.update(busy=True, phase="reading the prompt", prompt_tokens=len(ids), generated=0,
started=time.time(), first_token=None, tool=None, tail="", max_tokens=max_new)
self.last_request_at = time.time()
self.rate.clear() # the previous request's samples must not leak into this one
before = getattr(self.engine, "last", None)
last_print = time.time()
gen = self.engine.generate(ids, max_new, sampling, cancel, embeddings=emb) if emb else \
self.engine.generate(ids, max_new, sampling, cancel)
@@ -792,7 +1048,9 @@ class Service:
Path(emb).unlink(missing_ok=True)
with self.status_lock:
if self.status.get("busy"):
last = dict(getattr(self.engine, "last", {}) or {})
# only this request's DONE counts: same object means no DONE arrived (death, error, disconnect)
last = dict(getattr(self.engine, "last", {}) or {}) \
if getattr(self.engine, "last", None) is not engine_last0 else {}
started = self.status.get("started", time.time())
loaded = str((getattr(self.engine, "info", {}) or {}).get("cvec", 0)) not in ("0", "", "None")
hit_rate = round(last["hits"] / last["lookups"], 3) if last.get("lookups") else None
@@ -801,6 +1059,7 @@ class Service:
if loaded else None,
"time": started, "duration_s": round(time.time() - started, 1), "finish": finish,
"prompt_tokens": len(ids), "reused": last.get("reused"), "output_tokens": n,
"engine_generated": last.get("generated"),
"prompt_ms": last.get("prompt_ms"), "decode_ms": last.get("decode_ms"),
"decode_tok_s": round(last["generated"] / (last["decode_ms"] / 1000), 1)
if n and last.get("generated") and last.get("decode_ms") else None,
@@ -812,6 +1071,11 @@ class Service:
t["output_tokens"] += n
t["prompt_ms"] += last.get("prompt_ms") or 0.0
t["decode_ms"] += last.get("decode_ms") or 0.0
fresh = getattr(self.engine, "last", None)
if fresh is not None and fresh is not before: # the engine's clock for THIS request
timings = request_timings(len(ids), n, last)
self.last_timings = dict(timings, at=int(time.time())) if timings else None
self.last_request_at = time.time()
now = time.time()
el = now - self.status.get("started", now)
ft = self.status.get("first_token")
@@ -822,9 +1086,32 @@ class Service:
if os.environ.get("STRATA_DEBUG") and raw_ids:
print(f"[strata] raw: {self.tok.decode(raw_ids)!r}", flush=True)
self.status["busy"] = False
self.status.pop("tail", None) # #212: the answer's end is not kept once it is done
self.status.pop("tool", None)
for ev in parser.finish():
yield "event", ev
yield "done", {"finish": finish, "completion_tokens": n}
yield "done", {"finish": finish, "completion_tokens": n, "reused": (timings or {}).get("cache_n", 0),
"timings": timings}
def request_timings(prompt_tokens: int, generated: int, last: dict) -> dict | None:
"""One request's `timings` in llama.cpp's names (what its clients show as speed), from the engine's own clock
(StrataEngine.last): prompt_n is what was read, cache_n what the conversation cache already held. None when the
engine keeps no clock (MockEngine)."""
if last.get("prompt_ms") is None:
return None
cache_n = int(last.get("reused") or 0)
prompt_n, prompt_ms, decode_ms = max(0, prompt_tokens - cache_n), float(last["prompt_ms"]), float(last.get("decode_ms") or 0)
decoded = int(last.get("generated") or generated) # the engine's count gives its rate, as /metrics does
return {"cache_n": cache_n, "prompt_n": prompt_n, "prompt_ms": round(prompt_ms, 1),
"prompt_per_token_ms": round(prompt_ms / prompt_n, 3) if prompt_n else None,
"prompt_per_second": round(prompt_n / (prompt_ms / 1000), 1) if prompt_n and prompt_ms > 0 else None,
"predicted_n": generated, "predicted_ms": round(decode_ms, 1),
"predicted_per_token_ms": round(decode_ms / decoded, 3) if decoded else None,
"predicted_per_second": round(decoded / (decode_ms / 1000), 1) if decoded and decode_ms > 0 else None,
# the speculative drafts, as llama.cpp names them (from PR #83, @mikicvi): only when the engine reported them
**({"draft_n": int(last["drafts_offered"]), "draft_n_accepted": int(last["drafts_accepted"])}
if last.get("drafts_offered") is not None else {})}
def _debug_req(api, req, messages, tools, max_new, thinking, prompt_tokens):
@@ -975,7 +1262,11 @@ def openai_chunks(svc: Service, req: dict, ids, thinking, tools, max_new, cancel
last = chunk({}, finish)
pt = x.get("prompt_tokens", len(ids)) # after MCP rounds: the last round's prompt
last["usage"] = {"prompt_tokens": pt, "completion_tokens": x["completion_tokens"],
"total_tokens": pt + x["completion_tokens"]}
"total_tokens": pt + x["completion_tokens"],
# the part of the prompt the conversation cache already held (OpenAI's field)
"prompt_tokens_details": {"cached_tokens": x.get("reused") or 0}}
if x.get("timings"):
last["timings"] = x["timings"] # llama.cpp's field: the speed its clients show
yield last
@@ -1005,9 +1296,12 @@ def openai_collect(chunks) -> dict:
msg["tool_calls"] = calls
if mcp:
msg["strata_mcp"] = mcp
return {"id": last["id"], "object": "chat.completion", "created": last["created"], "model": last["model"],
"choices": [{"index": 0, "message": msg, "finish_reason": last["choices"][0]["finish_reason"]}],
"usage": last["usage"]}
out = {"id": last["id"], "object": "chat.completion", "created": last["created"], "model": last["model"],
"choices": [{"index": 0, "message": msg, "finish_reason": last["choices"][0]["finish_reason"]}],
"usage": last["usage"]}
if last.get("timings"):
out["timings"] = last["timings"]
return out
# ------------------------------------------------------------------------------------------------ Anthropic
@@ -1067,8 +1361,12 @@ def anthropic_events(svc: Service, req: dict, ids, thinking, tools, max_new, can
yield close()
stop = "tool_use" if used_tool and x["finish"] == "stop" else \
{"stop": "end_turn", "length": "max_tokens", "cancel": "end_turn"}[x["finish"]]
# the final counts, Anthropic's way: input_tokens leaves out what the conversation cache already held,
# which is cache_read_input_tokens (message_start could only say the whole prompt)
reused = min(x.get("reused") or 0, len(ids))
yield "message_delta", {"type": "message_delta", "delta": {"stop_reason": stop, "stop_sequence": None},
"usage": {"output_tokens": x["completion_tokens"]}}
"usage": {"input_tokens": len(ids) - reused, "cache_read_input_tokens": reused,
"output_tokens": x["completion_tokens"]}}
yield "message_stop", {"type": "message_stop"}
@@ -1095,7 +1393,7 @@ def anthropic_collect(events) -> dict:
b["input"] = json.loads(b.pop("_json") or "{}")
elif name == "message_delta":
msg["stop_reason"] = e["delta"]["stop_reason"]
msg["usage"]["output_tokens"] = e["usage"]["output_tokens"]
msg["usage"].update(e["usage"])
msg["content"] = blocks
return msg
@@ -1121,7 +1419,7 @@ def make_handler(svc: Service):
return True
auth = self.headers.get("Authorization", "")
given = auth[7:].strip() if auth.lower().startswith("bearer ") else self.headers.get("x-api-key", "")
if given == svc.api_key:
if hmac.compare_digest(given.encode(), svc.api_key.encode()): # #213: constant-time
return True
self._json(401, {"error": {"type": "authentication_error", "message": "missing or wrong API key"}})
return False
@@ -1184,21 +1482,46 @@ def make_handler(svc: Service):
self.wfile.write(body)
elif path == "/health":
self._json(200, {"status": "ok", "max_context": svc.engine.max_context, "model": svc.model,
"images": svc.vision is not None, "api_key": bool(svc.api_key)})
"images": svc.vision is not None, "api_key": bool(svc.api_key),
"loaded": svc.loaded()})
elif path == "/status":
if not self._authorized(): # #212: it shows the end of the last answer
return
with svc.status_lock:
s = dict(svc.status)
now = time.time()
if s.get("busy"):
s["elapsed_s"] = round(now - s["started"], 1)
if s.get("first_token"):
s["tokens_per_s"] = round(s["generated"] / max(1e-6, now - s["first_token"]), 1)
s["tokens_per_s"] = round(svc._tok_s(), 1)
s["tokens_per_s_mean"] = round(svc._tok_s_mean(), 1)
for k in ("started", "first_token"):
s.pop(k, None)
self._json(200, s)
elif path == "/v1/models":
elif path in ("/v1/models", "/models"):
if self._authorized():
self._json(200, {"object": "list", "data": [{"id": svc.model, "object": "model"}]})
loaded = svc.loaded()
model = {"id": svc.model, "object": "model", "status": {"value": "loaded"},
"meta": {"n_ctx": svc.engine.max_context},
"architecture": {"input_modalities": ["text", "image"] if svc.vision is not None else ["text"],
"output_modalities": ["text"]}}
if not loaded and (svc.idle_unload_s or getattr(svc.engine, "unloaded", False)):
model["status"] = {"value": "unloaded"} # like llama-server's router: listed, loads on use
loaded = True
self._json(200, {"object": "list", "data": [model] if loaded else []})
elif path == "/props":
if self._authorized():
self._props()
elif path == "/slots":
if self._authorized():
loaded = not hasattr(svc.engine, "alive") or svc.engine.alive()
with svc.status_lock:
busy = bool(svc.status.get("busy"))
slot = {"id": 0, "n_ctx": svc.engine.max_context, "is_processing": busy}
self._json(200, [slot] if loaded else [])
elif path == "/v1/status":
if self._authorized():
self._json(200, svc.v1_status())
else:
self._json(404, {"error": {"message": "not found"}})
@@ -1209,8 +1532,21 @@ def make_handler(svc: Service):
if path == "/settings":
self._settings()
return
if path == "/unload": # give the GPU back now (between requests)
r = svc.unload()
self._json(409 if r == "busy" else 200, {"status": r})
return
if path == "/load": # load now, e.g. ahead of a request
try:
svc.load()
self._json(200, {"status": "loaded"})
except GpuBusy as e:
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
return
try:
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
if path in ("/v1/chat/completions", "/v1/messages"):
svc.load() # unloaded: load first (or 503 while the GPU is busy)
if path == "/v1/chat/completions":
self._openai(req)
elif path == "/v1/messages":
@@ -1219,9 +1555,36 @@ def make_handler(svc: Service):
self._json(404, {"error": {"message": "not found"}})
except ValueError as e:
self._json(400, {"error": {"type": "invalid_request_error", "message": str(e)}})
except GpuBusy as e:
self._json(503, {"error": {"type": "server_error", "message": str(e)}})
except EngineDied as e: # before the answer started (not streamed)
self._json(503, {"error": {"type": "server_error", "message": f"{e}; the next request restarts it"}})
def _props(self):
model = parse_qs(urlsplit(self.path).query).get("model", [svc.model])[0]
if model != svc.model:
self._json(404, {"error": {"message": "model not found"}})
return
if not svc.loaded() and not getattr(svc.engine, "unloaded", False):
self._json(503, {"error": {"message": "the engine is not running"}})
return
defaults = {**svc.sampling_defaults, **svc.shared}
names = {"repetition_penalty": "repeat_penalty", "penalty_last_n": "repeat_last_n"}
params = {names.get(k, k): v for k, v in defaults.items()
if k in ("temperature", "top_p", "top_k", "min_p", "seed", "repetition_penalty",
"presence_penalty", "frequency_penalty", "penalty_last_n")}
params["n_predict"] = svc.shared.get("max_tokens", -1)
props = {"default_generation_settings": {"n_ctx": svc.engine.max_context, "params": params},
"total_slots": 1, "model_alias": svc.model, "chat_template": svc.template.source,
"modalities": {"vision": svc.vision is not None}, "models_autoload": False,
"is_sleeping": not svc.loaded()}
if getattr(svc.engine, "model_path", None):
props["model_path"] = svc.engine.model_path
version = getattr(svc.engine, "info", {}).get("version")
if version:
props["build_info"] = "Strata " + str(version)
self._json(200, props)
def _own_page(self, what) -> bool:
"""Only JSON (a form or a "simple" cross-site request can't send it without a CORS preflight, which this
server never grants) and no foreign Origin: a web page elsewhere must not change settings or run tools."""
@@ -1333,6 +1696,10 @@ class Server(ThreadingHTTPServer):
# either one (a forgotten second start of run-<model>.bat). Without it the second start fails loudly instead.
allow_reuse_address = os.name != "nt"
def handle_error(self, request, client_address):
if not isinstance(sys.exc_info()[1], ConnectionError): # a client that hangs up needs no stack trace
super().handle_error(request, client_address)
def warn_tight_ram(arena_mib) -> None:
"""The model's experts live in RAM (INFO arena_mib, engine 0.1.10+). With less than ~6 GB left beside them for the
@@ -1503,6 +1870,15 @@ def main() -> int:
ap.add_argument("--mcp-config", help="a JSON file with MCP servers in Claude Desktop's format ({\"mcpServers\": "
"{...}}); the web app's chat can use their tools (also \"mcp_servers\" in "
"the config)")
ap.add_argument("--idle-unload", type=float, default=None, metavar="SECONDS",
help="unload the model after this many seconds without requests, so other programs (games, other "
"model servers) can use the VRAM; the next request loads it again (also \"idle_unload_s\" "
"in the config; default: never)")
ap.add_argument("--min-free-vram-mib", type=int, default=None,
help="load an unloaded model only when this much VRAM is free, else answer 503 (also "
"\"min_free_vram_mib\" in the config; default: always load)")
ap.add_argument("--before-load", help="a command run before the model is loaded again (e.g. to unload another "
"server's model; also \"before_load\" in the config, a string or a list)")
a = ap.parse_args()
cfg = json.loads(Path(a.config).read_text(encoding="utf-8-sig")) if a.config else {} # Notepad adds a BOM
if a.gpu is not None:
@@ -1556,7 +1932,17 @@ def main() -> int:
model_name=cfg.get("model_name", "qwen3.8-flash-next"), vision=vision,
sampling_defaults=sampling_defaults,
fit_max_tokens=a.fit_max_tokens or cfg.get("fit_max_tokens") is True)
if ("STRATA_API_KEY" in os.environ and not os.environ["STRATA_API_KEY"].strip()) or any(x == "--api-key" and i + 1 < len(sys.argv) and not sys.argv[i + 1].strip() or x.strip() == "--api-key="
for i, x in enumerate(sys.argv)):
# #213: an empty key would switch authentication off without a word
print("[strata] an API key was given but it is empty: set a key, or leave --api-key / STRATA_API_KEY out",
file=sys.stderr)
return 2
svc.api_key = a.api_key or cfg.get("api_key", "")
svc.idle_unload_s = a.idle_unload if a.idle_unload is not None else float(cfg.get("idle_unload_s") or 0)
svc.min_free_vram_mib = a.min_free_vram_mib if a.min_free_vram_mib is not None else \
int(cfg.get("min_free_vram_mib") or 0)
svc.before_load = a.before_load or cfg.get("before_load") or None
svc.gpu_index = (gpu_list(cfg) or [0])[0] # the Monitor reads the card the engine runs on (issue #51)
svc.gpu_indices = gpu_list(cfg) # ... or every card of a layer split (issue #112)
if a.config: # the Chat settings shared with other apps, from last time
@@ -1576,6 +1962,7 @@ def main() -> int:
hub.start()
atexit.register(hub.close) # the servers Strata started end with it
httpd = serve(svc, host=a.host, port=a.port)
svc.start_idle_unload()
here = "127.0.0.1" if a.host in ("0.0.0.0", "", "::") else a.host
print(f"ready: http://{here}:{a.port}/v1 (OpenAI: /v1/chat/completions, Anthropic: /v1/messages, "
f"context {engine.max_context} tokens{', images on' if vision else ''}"
@@ -1599,16 +1986,29 @@ def main() -> int:
if a.open:
import webbrowser
webbrowser.open(f"http://{'127.0.0.1' if a.host in ('0.0.0.0', '') else a.host}:{a.port}/")
# #96: docker stop sends SIGTERM, which Python ignores by default, so the container's PID 1 would be killed after
# the grace period with the engine still running. SIGTERM takes Ctrl+C's path below (QUIT to the engine).
# SIGINT keeps Python's own handler, so Ctrl+C and a second Ctrl+C work as before.
def on_sigterm(signum, frame):
raise KeyboardInterrupt
try:
threading.Event().wait()
signal.signal(signal.SIGTERM, on_sigterm)
except (ValueError, OSError, AttributeError): # not the main thread
pass
try:
while True:
time.sleep(1) # Windows never delivers Ctrl+C to an untimed Event.wait()
except KeyboardInterrupt:
httpd.shutdown()
if hasattr(engine, "close"):
engine.close()
if vision:
vision.close()
if hub is not None:
hub.close()
print("\n[strata] stopping (Ctrl+C again to end the engine at once) ...", flush=True)
closers = [httpd.shutdown, getattr(engine, "close", None), vision.close if vision else None,
hub.close if hub is not None else None]
for close in filter(None, closers):
try:
close()
except KeyboardInterrupt: # a second Ctrl+C: don't wait for the engine to free its memory
if getattr(engine, "proc", None):
engine.proc.kill()
print("[strata] stopped", flush=True)
return 0
+1 -1
View File
@@ -256,7 +256,7 @@ class Telemetry:
with self.lock:
self.now = s
for k in ("gpu_util", "gpu_mem_used", "gpu_temp", "gpu_power", "gpu_pcie_rx_mb", "cpu", "ram_used",
"disk_read_mb", "tok_s"):
"disk_read_mb", "tok_s", "prefill_tok_s_mean"):
v = s.get(k)
self.hist[k].append(round(v, 2) if isinstance(v, float) else v)
time.sleep(1.0)
+546 -2
View File
@@ -7,6 +7,9 @@ from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import time
import unittest
import urllib.error
import urllib.request
@@ -14,7 +17,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from serve.frontend import ChatTemplate # noqa: E402
from serve.server import CTX_SLACK, ByteTokenizer, EngineDied, MockEngine, Service, StrataEngine, serve # noqa: E402
from serve.server import CTX_SLACK, ByteTokenizer, EngineDied, GpuBusy, MockEngine, Service, StrataEngine, request_timings, serve # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
CTX = 4096
@@ -165,6 +168,90 @@ class FitMaxTokens(unittest.TestCase):
self.assertIn("no room to answer", b["error"]["message"])
class ImageMarkers(unittest.TestCase):
"""#150: the text "<|image_pad|>" inside a message is text, not an image's place."""
class FakeVision:
def __init__(self, d):
self.dir = Path(d)
self.rows = self.dir / "img.sve"
self.rows.write_bytes(b"rows")
def encode(self, source):
return self.rows, 3
def test_literal_marker_with_an_image(self):
import tempfile
tok = ByteTokenizer()
with tempfile.TemporaryDirectory() as d:
svc = Service(MockEngine(tok, "ok", max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"),
vision=self.FakeVision(d))
pad = tok.encode("<|image_pad|>", parse_special=True)[0]
for text in ("the docs say <|image_pad|> marks an image", "plain"):
with self.subTest(text=text):
msgs = [{"role": "user", "content": [{"type": "text", "text": text},
{"type": "image", "source": "x.png"}]}]
ids, _, _ = svc.prepare(msgs, None, {})
self.assertEqual(ids.count(pad), 3) # the image's three rows, nothing else
self.assertIn("<|image_pad|> marks" if "docs" in text else "plain", tok.decode(ids))
svc.embeddings.path.unlink(missing_ok=True)
class StatusNeedsTheKey(unittest.TestCase):
"""#212: /status shows the end of the answer being written, so it needs the key like /v1/*."""
def test_status(self):
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "ok", max_context=CTX), tok, ChatTemplate(ROOT / "serve/chat_template.jinja"))
svc.api_key = "k3y"
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}/status"
try:
with self.assertRaises(urllib.error.HTTPError) as e:
urllib.request.urlopen(base, timeout=10)
self.assertEqual(e.exception.code, 401)
e.exception.close()
req = urllib.request.Request(base, headers={"Authorization": "Bearer k3y"})
with urllib.request.urlopen(req, timeout=10) as r:
self.assertEqual(r.status, 200)
self.assertNotIn("tail", json.loads(r.read()))
finally:
httpd.shutdown()
httpd.server_close()
class ToolCallTerminators(unittest.TestCase):
"""#210: a value that contains </parameter> or </tool_call> (a file documenting the call format) is kept whole."""
CONTENT = ("Close each value with </parameter> and the call with </function></tool_call>.\n"
"<parameter=x>\nnot a parameter\n</parameter>\nend")
SCHEMA = [{"name": "write", "parameters": {"properties": {"path": {"type": "string"},
"content": {"type": "string"}}}}]
def run_parser(self, stream_tools, step):
from serve.frontend import OutputParser
text = ("</think>\n\n<tool_call>\n<function=write>\n<parameter=path>\ndoc.md\n</parameter>\n"
f"<parameter=content>\n{self.CONTENT}\n</parameter>\n</function>\n</tool_call>")
p = OutputParser(thinking=True, tools=self.SCHEMA, stream_tools=stream_tools)
evs = []
for i in range(0, len(text), step):
evs += p.feed(text[i:i + step])
evs += p.finish()
return evs
def test_values_keep_the_terminators(self):
for stream_tools in (False, True):
for step in (1, 7, 10_000):
with self.subTest(stream_tools=stream_tools, step=step):
evs = self.run_parser(stream_tools, step)
calls = [e.call for e in evs if e.kind == "tool_call"]
self.assertEqual(len(calls), 1)
self.assertEqual(calls[0].arguments, {"path": "doc.md", "content": self.CONTENT})
self.assertFalse([e for e in evs if e.kind == "content" and e.text.strip()])
if stream_tools:
streamed = "".join(e.text for e in evs if e.kind == "tool_args")
self.assertEqual(json.loads(streamed), {"path": "doc.md", "content": self.CONTENT})
class ClientShapes(unittest.TestCase):
"""What real clients send: Claude Code posts /v1/messages?beta=true (issue #55) and puts hook context into the
conversation as a mid-conversation system message (issue #56); some OpenAI clients send a late developer message."""
@@ -368,6 +455,132 @@ class EngineDeath(unittest.TestCase):
httpd.server_close()
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.
It used to be `generated / (now - first_token)` - the mean since the first token, whose first sample is
1/elapsed. Against a paced engine that reads five-digit numbers for the first instant of every answer and
undershoots for the first second after that. It is now the rate over the last RATE_WINDOW_S, with the mean
still available as `live.tok_s_mean` for anyone who wants it."""
PACE_S = 0.02 # 50 tokens/s: a 30-token answer takes about 0.6 s
TOKENS = 30
def setUp(self):
self.tok = ByteTokenizer()
self.engine = MockEngine(self.tok, "x" * self.TOKENS, max_context=CTX, delay_s=self.PACE_S)
self.svc = Service(self.engine, self.tok, ChatTemplate(ROOT / "serve/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 metrics(self):
with urllib.request.urlopen(self.base + "/metrics", timeout=10) as r:
return json.loads(r.read())
def test_prefill_rate_excludes_cached_tokens(self):
import io
import queue
from types import SimpleNamespace
engine = StrataEngine.__new__(StrataEngine)
engine.proc = SimpleNamespace(stdin=io.StringIO(), poll=lambda: None) # alive() asks it (#208)
engine.lines = queue.Queue()
engine.can_stop = False
engine.max_context = 262144
engine.prefill_tok_s_mean = 9999.0
engine.lines.put("PP 10000 12000 2000 1000.0") # 8000 cached, 2000 newly read in two seconds
engine.lines.put("DONE 1 12000 4000 10 stop 0 0 8000")
gen = engine.generate([1], 1, {}, threading.Event())
self.assertIsNone(next(gen))
self.assertEqual(engine.progress, (10000, 12000))
self.assertEqual(engine.prefill_tok_s_mean, 1000.0)
self.svc.engine = engine
self.svc.status.update(busy=True, first_token=None)
self.assertEqual(self.metrics()["live"]["prefill_tok_s_mean"], 1000.0)
self.assertNotIn("prefill_tok_s", self.metrics()["live"])
self.assertEqual(self.svc._prefill_tok_s_mean(), 1000.0)
self.svc.status.update(first_token=time.time(), generated=1)
self.assertEqual(self.svc._prefill_tok_s_mean(), 0.0)
self.assertEqual(list(gen), [])
timings = request_timings(12000, 1, engine.last)
self.assertEqual(timings["prompt_per_second"], 1000.0)
engine.lines.put("PP 8000 12000")
engine.lines.put("DONE 0 12000 0 0 stop 0 0 12000")
gen = engine.generate([1], 1, {}, threading.Event())
next(gen)
self.assertIsNone(engine.prefill_tok_s_mean)
list(gen)
self.svc.status["busy"] = False
self.assertIsNone(self.metrics()["live"]["prefill_tok_s_mean"])
def test_the_live_number_is_a_rate(self):
live_samples, stop = [], threading.Event()
def poll(): # what the Monitor polls, at 10 ms
while not stop.is_set():
live = self.metrics()["live"]
if live["state"] == "generating" and live["tok_s"] is not None:
live_samples.append((live["generated"], live["tok_s"], live["tok_s_mean"]))
time.sleep(0.01)
body = json.dumps({"model": "m", "max_tokens": self.TOKENS, "temperature": 0,
"messages": [{"role": "user", "content": "hi"}]}).encode()
watcher = threading.Thread(target=poll, daemon=True)
watcher.start()
t0 = time.time()
try:
with urllib.request.urlopen(urllib.request.Request(self.base + "/v1/chat/completions", data=body,
headers={"Content-Type": "application/json"}),
timeout=30) as r:
usage = json.loads(r.read())["usage"]
finally:
stop.set()
watcher.join(2)
true_rate = usage["completion_tokens"] / (time.time() - t0)
self.assertGreaterEqual(len(live_samples), 3, "too few live readings to judge the readout")
self.assertLess(max(s for _g, s, _m in live_samples), 4 * true_rate,
f"live.tok_s peaked at {max(s for _g, s, _m in live_samples):.1f} tok/s "
f"for a {true_rate:.1f} tok/s engine")
self.assertEqual(self.metrics()["live"]["state"], "idle")
self.assertIsNone(self.metrics()["live"]["tok_s"])
def test_a_request_without_a_done_keeps_no_engine_counters(self):
"""An engine that dies mid-answer: the previous request's `last` must not become this row's decode rate."""
class HalfDead(MockEngine):
last = {"generated": 99, "prompt_tokens": 9, "prompt_ms": 10.0, "decode_ms": 100.0, "finish": "stop"}
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
for i, t in enumerate(super().generate(ids, max_new, sampling, cancel, embeddings)):
if i == 3:
raise EngineDied("the engine stopped unexpectedly (exit code -9)")
yield t
tok = ByteTokenizer()
svc = Service(HalfDead(tok, "x" * self.TOKENS, max_context=CTX), tok,
ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
base = f"http://127.0.0.1:{httpd.server_address[1]}"
try:
body = json.dumps({"model": "m", "max_tokens": self.TOKENS, "stream": True,
"messages": [{"role": "user", "content": "hi"}]}).encode()
with urllib.request.urlopen(urllib.request.Request(base + "/v1/chat/completions", data=body,
headers={"Content-Type": "application/json"}),
timeout=30) as r:
text = r.read().decode()
self.assertIn('"error"', text)
row = svc.metrics()["requests"][0]
self.assertEqual(row["finish"], "error")
self.assertEqual(row["output_tokens"], 3)
self.assertIsNone(row["decode_tok_s"], "the previous request's counters were recorded as this one's")
self.assertIsNone(row["engine_generated"])
finally:
httpd.shutdown()
httpd.server_close()
class SharedSettings(unittest.TestCase):
"""The web app's "Use for other apps too": POST /settings makes its Chat settings every client's defaults."""
@@ -489,7 +702,7 @@ class WebApp(unittest.TestCase):
code, ctype, body = self.get("/")
self.assertEqual(code, 200)
self.assertIn("text/html", ctype)
self.assertIn(b"/web/app.js", body)
self.assertIn(b"\"web/app.js\"", body) # relative since #82 (works behind a path-prefixed proxy)
for path, want in (("/web/app.js", "javascript"), ("/web/app.css", "text/css"), ("/web/tokens.css", "text/css"),
("/web/components.css", "text/css"), ("/web/sprite.svg", "image/svg+xml")):
with self.subTest(path=path):
@@ -516,6 +729,86 @@ class WebApp(unittest.TestCase):
self.assertEqual(m["live"]["state"], "idle")
self.assertEqual(m["requests"][0]["output_tokens"], 5)
def test_model_discovery_and_props(self):
svc = self.svc
previous = svc.engine.max_context, svc.vision, svc.sampling_defaults, svc.shared
try:
svc.engine.max_context = 262144
svc.sampling_defaults = {"temperature": 1.0, "repetition_penalty": 1.1}
svc.shared = {"temperature": 0.7, "max_tokens": 4096}
for vision in (None, object()):
svc.vision = vision
for path in ("/models", "/v1/models"):
code, _, body = self.get(path)
self.assertEqual(code, 200)
models = json.loads(body)["data"]
self.assertEqual(len(models), 1)
model = models[0]
self.assertEqual(model["id"], svc.model)
self.assertEqual(model["status"]["value"], "loaded")
self.assertEqual(model["meta"]["n_ctx"], 262144)
self.assertEqual(model["architecture"]["input_modalities"],
["text", "image"] if vision else ["text"])
code, _, body = self.get("/props?model=" + svc.model + "&autoload=false")
self.assertEqual(code, 200)
props = json.loads(body)
self.assertEqual(props["default_generation_settings"]["n_ctx"], 262144)
self.assertEqual(props["default_generation_settings"]["params"],
{"temperature": 0.7, "repeat_penalty": 1.1, "n_predict": 4096})
self.assertEqual(props["chat_template"], (ROOT / "serve/chat_template.jinja").read_text(encoding="utf-8"))
self.assertEqual(props["modalities"]["vision"], vision is not None)
self.assertEqual(props["total_slots"], 1)
self.assertFalse(props["models_autoload"])
svc.shared = {}
props = json.loads(self.get("/props")[2])
self.assertEqual(props["default_generation_settings"]["params"]["n_predict"], -1)
self.assertEqual(self.get("/props?model=not-loaded&autoload=true")[0], 404)
finally:
svc.engine.max_context, svc.vision, svc.sampling_defaults, svc.shared = previous
def test_discovery_needs_the_api_key(self):
self.svc.api_key = "secret"
try:
for path in ("/models", "/v1/models", "/props", "/slots"):
self.assertEqual(self.get(path)[0], 401)
self.assertEqual(self.get(path, {"Authorization": "Bearer secret"})[0], 200)
finally:
self.svc.api_key = ""
def test_build_model_path_and_slot_status(self):
engine = self.svc.engine
engine.model_path = "models/example.gguf"
engine.info = {"version": "0.1.21"}
try:
props = json.loads(self.get("/props")[2])
self.assertEqual(props["model_path"], engine.model_path)
self.assertEqual(props["build_info"], "Strata 0.1.21")
for busy in (True, False):
with self.svc.status_lock:
self.svc.status["busy"] = busy
code, _, body = self.get("/slots")
self.assertEqual(code, 200)
self.assertEqual(json.loads(body), [{"id": 0, "n_ctx": CTX, "is_processing": busy}])
finally:
with self.svc.status_lock:
self.svc.status["busy"] = False
del engine.model_path, engine.info
props = json.loads(self.get("/props")[2])
self.assertNotIn("build_info", props)
self.assertNotIn("model_path", props)
def test_discovery_does_not_restart_a_dead_engine(self):
self.svc.engine.alive = lambda: False
try:
for path in ("/models", "/v1/models"):
code, _, body = self.get(path)
self.assertEqual(code, 200)
self.assertEqual(json.loads(body)["data"], [])
self.assertEqual(self.get("/props")[0], 503)
self.assertEqual(json.loads(self.get("/slots")[2]), [])
finally:
del self.svc.engine.alive
def test_metrics_need_the_key_when_one_is_set(self):
self.svc.api_key = "secret"
try:
@@ -526,5 +819,256 @@ class WebApp(unittest.TestCase):
self.svc.api_key = ""
class ClockedEngine(MockEngine):
"""The mock engine with StrataEngine's clock: `last` as the engine's DONE line gives it, the conversation cache
holding the first REUSED tokens of every prompt."""
REUSED = 5
def generate(self, ids, max_new, sampling, cancel, embeddings=None):
n = 0
try:
for t in super().generate(ids, max_new, sampling, cancel, embeddings):
n += 1
yield t
finally: # as StrataEngine reads its DONE line: also when the server closes the request at a stop token
self.last = {"generated": n, "prompt_tokens": len(ids), "prompt_ms": 40.0, "decode_ms": 20.0 * n,
"finish": "stop", "reused": min(self.REUSED, len(ids)), "hits": 9, "lookups": 10}
class UsageAndStatus(unittest.TestCase):
"""What clients read besides the text: the part of the prompt the conversation cache held (OpenAI's
prompt_tokens_details.cached_tokens, Anthropic's cache_read_input_tokens), llama.cpp's timings, GET /v1/status."""
@classmethod
def setUpClass(cls):
tok = ByteTokenizer()
cls.engine = ClockedEngine(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.base = f"http://127.0.0.1:{cls.httpd.server_address[1]}"
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
def request(self, path, body=None):
req = urllib.request.Request(self.base + path, data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", "anthropic-version": "2023-06-01"})
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read()
def chat(self, path, stream=False):
body = {"model": "x", "max_tokens": 20, "messages": [{"role": "user", "content": "hi"}], "stream": stream}
status, raw = self.request(path, body)
self.assertEqual(status, 200)
if not stream:
return json.loads(raw)
return [json.loads(line[6:]) for line in raw.decode().splitlines()
if line.startswith("data: {")]
def test_openai(self):
b = self.chat("/v1/chat/completions")
u, t = b["usage"], b["timings"]
self.assertEqual(u["prompt_tokens_details"]["cached_tokens"], ClockedEngine.REUSED)
self.assertEqual(t["cache_n"], ClockedEngine.REUSED)
self.assertEqual(t["prompt_n"] + t["cache_n"], u["prompt_tokens"])
self.assertEqual(t["predicted_n"], u["completion_tokens"])
self.assertAlmostEqual(t["prompt_per_second"], t["prompt_n"] / 0.040, delta=0.1)
self.assertAlmostEqual(t["predicted_per_second"], 50.0, delta=0.1) # 20 ms a token
def test_openai_stream(self):
last = self.chat("/v1/chat/completions", stream=True)[-1]
self.assertEqual(last["usage"]["prompt_tokens_details"]["cached_tokens"], ClockedEngine.REUSED)
self.assertEqual(last["timings"]["cache_n"], ClockedEngine.REUSED)
def test_anthropic(self):
u = self.chat("/v1/messages")["usage"]
self.assertEqual(u["cache_read_input_tokens"], ClockedEngine.REUSED)
self.assertEqual(u["input_tokens"] + u["cache_read_input_tokens"], len(self.engine.last_prompt))
self.assertGreater(u["output_tokens"], 0)
def test_v1_status(self):
self.chat("/v1/chat/completions")
status, raw = self.request("/v1/status")
self.assertEqual(status, 200)
s = json.loads(raw)
self.assertEqual(s["model"], self.svc.model)
self.assertEqual(s["context"]["max_positions"], CTX)
self.assertEqual(s["concurrency"]["serving"], 1)
self.assertFalse(s["vision"]["available"])
self.assertEqual(s["activity"]["in_flight"], 0)
self.assertGreaterEqual(s["activity"]["requests"], 1)
self.assertEqual(s["last_timings"]["cache_n"], ClockedEngine.REUSED)
self.assertIn("at", s["last_timings"])
def test_no_clock(self):
"""An engine without a clock (MockEngine): no timings, nothing cached."""
tok = ByteTokenizer()
svc = Service(MockEngine(tok, "</think>\n\nok", max_context=CTX), tok,
ChatTemplate(ROOT / "serve/chat_template.jinja"))
httpd = serve(svc, port=0)
try:
data = json.dumps({"model": "x", "max_tokens": 5, "messages": [{"role": "user", "content": "hi"}]}).encode()
req = urllib.request.Request(f"http://127.0.0.1:{httpd.server_address[1]}/v1/chat/completions", data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
b = json.loads(r.read())
self.assertNotIn("timings", b)
self.assertEqual(b["usage"]["prompt_tokens_details"]["cached_tokens"], 0)
self.assertIsNone(svc.v1_status()["last_timings"])
finally:
httpd.shutdown()
httpd.server_close()
class TimingsDrafts(unittest.TestCase):
"""`timings` carries the speculative draft counts (PR #83's fields) only when the engine reported them."""
def test_draft_fields(self):
base = {"prompt_ms": 100.0, "decode_ms": 200.0, "generated": 20, "reused": 4}
t = request_timings(24, 20, dict(base, drafts_offered=15, drafts_accepted=11))
self.assertEqual((t["draft_n"], t["draft_n_accepted"]), (15, 11))
self.assertEqual((t["prompt_n"], t["cache_n"]), (20, 4))
self.assertNotIn("draft_n", request_timings(24, 20, base))
self.assertIsNone(request_timings(24, 20, {}))
class UnloadableEngine(MockEngine):
"""A mock engine that can be stopped and started again like StrataEngine (alive / unload / restart)."""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.running, self.unloaded, self.starts = True, False, 0
def alive(self):
return self.running
def unload(self):
self.running, self.unloaded = False, True
def restart(self):
self.running, self.unloaded = True, False
self.starts += 1
class SharingTheGpu(unittest.TestCase):
"""Idle unload, POST /unload and /load, the free-VRAM guard and the before_load hook (all off by default)."""
def setUp(self):
tok = ByteTokenizer()
self.engine = UnloadableEngine(tok, "</think>\n\nok", max_context=CTX)
self.svc = Service(self.engine, tok, ChatTemplate(ROOT / "serve/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 req(self, path, body=None):
r = urllib.request.Request(self.base + path, data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json"}, method="GET" if body is None else "POST")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e:
with e:
return e.code, json.loads(e.read())
def chat(self):
return self.req("/v1/chat/completions", {"model": "m", "messages": [{"role": "user", "content": "hi"}]})
def test_unload_then_the_next_request_loads(self):
self.assertEqual(self.req("/unload", {}), (200, {"status": "unloaded"}))
self.assertFalse(self.engine.alive())
self.assertEqual(self.req("/health")[1]["loaded"], False)
self.assertEqual(self.req("/v1/models")[1]["data"][0]["status"]["value"], "unloaded")
self.assertEqual(self.req("/unload", {}), (200, {"status": "not loaded"}))
s, b = self.chat()
self.assertEqual(s, 200)
self.assertEqual(b["choices"][0]["message"]["content"], "ok")
self.assertEqual(self.engine.starts, 1)
self.assertEqual(self.req("/health")[1]["loaded"], True)
def test_load_endpoint(self):
self.svc.unload()
self.assertEqual(self.req("/load", {}), (200, {"status": "loaded"}))
self.assertTrue(self.engine.alive())
self.assertEqual(self.req("/load", {}), (200, {"status": "loaded"}))
self.assertEqual(self.engine.starts, 1)
def test_unload_refused_while_a_request_runs(self):
with self.svc.fifo:
self.assertEqual(self.svc.unload(), "busy")
self.assertTrue(self.engine.alive())
def test_idle_unload(self):
self.svc.idle_unload_s = 1
self.svc.last_request_at = time.time()
self.assertEqual(self.svc.unload(idle_for=1), "busy") # a request just now: not idle yet
self.svc.start_idle_unload()
deadline = time.time() + 10
while self.engine.alive() and time.time() < deadline:
time.sleep(0.1)
self.assertFalse(self.engine.alive())
self.assertEqual(self.chat()[0], 200)
def test_min_free_vram_refuses_to_load(self):
self.svc.min_free_vram_mib = 8000
self.svc.free_vram_mib = lambda: 2000
self.svc.unload()
t0 = time.time()
s, b = self.chat()
self.assertEqual(s, 503)
self.assertIn("in use by another program", b["error"]["message"])
self.assertFalse(self.engine.alive())
self.assertGreater(time.time() - t0, 10) # waited for memory being given back first
self.svc.free_vram_mib = lambda: 9000
self.assertEqual(self.chat()[0], 200)
def test_min_free_vram_unreadable_loads(self):
self.svc.min_free_vram_mib = 8000
self.svc.free_vram_mib = lambda: None # no NVML: never refuse
self.svc.unload()
self.assertEqual(self.chat()[0], 200)
def test_before_load_runs_first(self):
mark = Path(tempfile.mkdtemp()) / "ran"
self.svc.before_load = [sys.executable, "-c", f"open({str(mark)!r}, 'w').close()"]
self.svc.unload()
self.assertFalse(mark.exists())
self.assertEqual(self.chat()[0], 200)
self.assertTrue(mark.exists())
def test_vision_encoder_unloads_and_starts_first(self):
order = []
class FakeVision:
running = True
def alive(self):
return self.running
def unload(self):
self.running = False
def restart(self):
order.append("vision")
self.running = True
engine_restart = self.engine.restart
self.engine.restart = lambda: (order.append("engine"), engine_restart())
self.svc.vision = FakeVision()
self.assertEqual(self.svc.unload(), "unloaded")
self.assertFalse(self.svc.vision.alive())
self.assertEqual(self.chat()[0], 200)
self.assertEqual(order, ["vision", "engine"]) # the encoder first, as at a start
self.assertTrue(self.svc.vision.alive())
def test_off_by_default(self):
self.assertEqual((self.svc.idle_unload_s, self.svc.min_free_vram_mib, self.svc.before_load), (0, 0, None))
self.assertEqual(self.req("/health")[1]["loaded"], True)
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""serve/winjob.py: a contained child - and whatever it starts - ends when the process that contained it ends, even
when that process is killed outright (the console window closed, Task Manager), with no cleanup of its own.
python -m unittest serve.test_winjob (Windows only; skipped elsewhere)
"""
import os
import subprocess
import sys
import time
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# stands in for the server: contains a child that starts a grandchild, prints both pids, then waits to be killed
PARENT = f"""
import subprocess, sys, time
sys.path.insert(0, {str(ROOT)!r})
from serve.winjob import contain
code = ("import subprocess, sys, time; g = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(120)']); "
"print(g.pid, flush=True); time.sleep(120)")
child = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, text=True)
ok = contain(child)
print(ok, child.pid, child.stdout.readline().strip(), flush=True)
time.sleep(120)
"""
def running(pid: int) -> bool:
out = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"], capture_output=True, text=True).stdout
return str(pid) in out
@unittest.skipUnless(os.name == "nt", "job objects are Windows-only")
class TestWinJob(unittest.TestCase):
def test_children_end_with_a_killed_server(self):
# a venv's python.exe is a launcher that runs the real interpreter as its child: kill() must reach the latter
python = getattr(sys, "_base_executable", None) or sys.executable
parent = subprocess.Popen([python, "-c", PARENT], stdout=subprocess.PIPE, text=True)
child = grandchild = None
try:
ok, child, grandchild = parent.stdout.readline().split()
child, grandchild = int(child), int(grandchild)
self.assertEqual(ok, "True")
self.assertTrue(running(child) and running(grandchild))
parent.kill() # TerminateProcess: no atexit, no finally - like closing the window
parent.wait(10)
deadline = time.monotonic() + 10
while (running(child) or running(grandchild)) and time.monotonic() < deadline:
time.sleep(0.2)
self.assertFalse(running(child), "the child outlived the server")
self.assertFalse(running(grandchild), "the grandchild outlived the server")
finally:
if parent.poll() is None:
parent.kill()
for pid in (child, grandchild): # on a failure, don't leave them sleeping
if pid and running(pid):
subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True)
def test_elsewhere_or_without_a_process_it_is_a_no_op(self):
from serve.winjob import contain
self.assertFalse(contain(None))
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -92,6 +92,11 @@ main { flex: 1; min-height: 0; display: flex; }
.st-metric__value small { font-size: var(--st-fs-md); font-weight: var(--st-fw-medium); color: var(--st-ink-muted); margin-left: 4px; }
.st-metric__sub { font-size: var(--st-fs-xs); color: var(--st-ink-muted); font-variant-numeric: tabular-nums; min-height: 1em; }
.st-metric__spark { display: block; overflow: visible; }
.speed-values { display: flex; justify-content: space-between; gap: var(--st-s-3); }
.speed-values > div { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.speed-values > div:last-child { text-align: right; }
.speed-values .st-metric__value { font-size: clamp(20px, 2.2vw, var(--st-fs-stat)); }
.st-metric__spark .speed-prefill { color: color-mix(in srgb, var(--st-accent) 65%, black); }
.st-metric__spark[data-tone="warn"] { color: var(--st-warn); }
.st-metric__spark[data-tone="info"] { color: var(--st-info); }
.monitor-row { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.4fr); gap: var(--st-s-4); }
+24 -12
View File
@@ -3,7 +3,7 @@
"use strict";
const $ = (id) => document.getElementById(id);
const SPRITE = "/web/sprite.svg";
const SPRITE = "web/sprite.svg";
const icon = (name, cls = "st-icon") => `<svg class="${cls}" aria-hidden="true"><use href="${SPRITE}#i-${name}"/></svg>`;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
const fmt = (n, d = 0) => (n == null || Number.isNaN(n) ? "–" : Number(n).toLocaleString(undefined, {maximumFractionDigits: d, minimumFractionDigits: d}));
@@ -98,7 +98,7 @@ $("api-key").onchange = () => { store.set("apikey", $("api-key").value.trim());
let health = {model: "strata", images: false, max_context: 0};
async function loadHealth() {
try {
health = await (await fetch("/health")).json();
health = await (await fetch("health")).json();
$("attach-btn").title = health.images ? "Attach a text file or a picture (or drop it here)"
: "Attach a text file (or drop it here)";
$("chat-empty-sub").textContent = `${health.model} runs on this PC. Nothing leaves it.`;
@@ -109,7 +109,7 @@ async function loadHealth() {
// ------------------------------------------------------------------ Monitor
const METRICS = [
{key: "speed", label: "Speed", icon: "gauge", unit: "tok/s", series: "tok_s"},
{key: "speed", label: "Speed", icon: "gauge", unit: "t/s", series: "tok_s"},
{key: "gpu", label: "GPU load", icon: "gpu", unit: "%", series: "gpu_util", max: 100},
{key: "vram", label: "VRAM", icon: "layers", unit: "GB", series: "gpu_mem_used"},
{key: "temp", label: "GPU temp", icon: "thermometer", unit: "°C", series: "gpu_temp", tone: "warn"},
@@ -121,11 +121,17 @@ const METRICS = [
$("metrics").innerHTML = METRICS.map((m) => `
<div class="st-card metric-card"><div class="st-metric">
<span class="st-metric__label">${icon(m.icon, "st-icon st-icon--sm")}${esc(m.label)}</span>
<span class="st-metric__value" id="mv-${m.key}">–</span>
<span class="st-metric__sub" id="ms-${m.key}"></span>
${m.key === "speed" ? `<div class="speed-values">
<div><span class="st-metric__value" id="mv-speed">-</span><span class="st-metric__sub" id="ms-speed">Decode</span></div>
<div class="speed-prefill"><span class="st-metric__value" id="mv-prefill">-</span><span class="st-metric__sub" id="ms-prefill">Prefill</span></div>
</div>` : `<span class="st-metric__value" id="mv-${m.key}">–</span>
<span class="st-metric__sub" id="ms-${m.key}"></span>`}
<svg class="st-metric__spark" id="sp-${m.key}" viewBox="0 0 100 32" preserveAspectRatio="none"${m.tone ? ` data-tone="${m.tone}"` : ""}>
<path class="area" fill="currentColor" opacity=".12"/><path class="line" fill="none" stroke="currentColor"
stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/></svg>
stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
${m.key === "speed" ? `<g id="sp-prefill" class="speed-prefill"><path class="area" fill="currentColor" opacity=".12"/>
<path class="line" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"
stroke-linecap="round" vector-effect="non-scaling-stroke"/></g>` : ""}</svg>
</div></div>`).join("");
function spark(id, values, max) {
@@ -147,7 +153,7 @@ let lastMetrics = null, metricsFailures = 0, keyWarned = false, mcpTick = 0;
let reqShowAll = false; // the Monitor's request table: the last 12, or every one the server keeps (issue #35)
async function poll() {
try {
const r = await fetch(reqShowAll ? "/metrics?requests=all" : "/metrics", {headers: headers()});
const r = await fetch(reqShowAll ? "metrics?requests=all" : "metrics", {headers: headers()});
if (r.status === 401) {
setPill("error", "API key needed");
if (!keyWarned) { keyWarned = true; toast("warn", "API key needed", "This server needs a key: add it under About > Settings.", 6000); }
@@ -226,8 +232,14 @@ function renderMonitor(live, hw, st, eng, h, last, requests, totals, kept) {
// the eight cards
const speed = live.state === "generating" ? live.tok_s : last ? last.decode_tok_s : null;
setMetric("speed", speed == null ? null : fmt(speed, 1), "tok/s", live.state === "generating" ? "now" : last ? "last request" : "");
setMetric("speed", speed == null ? null : fmt(speed, 1), "t/s",
live.state === "generating" ? "Decode now" : last ? "Decode last request" : "Decode");
const prefill = live.state !== "idle" ? live.prefill_tok_s_mean
: last && last.prompt_ms > 0 ? Math.max(0, last.prompt_tokens - (last.reused || 0)) / (last.prompt_ms / 1000) : null;
setMetric("prefill", prefill == null ? null : fmt(prefill), "t/s",
live.state === "reading" ? "Prefill now" : live.state === "generating" ? "Prefill this request" : last ? "Prefill last request" : "Prefill");
spark("sp-speed", h.tok_s);
spark("sp-prefill", h.prefill_tok_s_mean);
// a model split across several cards (issue #112): the cards show their total / mean / hottest, and each card's own
const per = (f) => (hw.gpus || []).map((g) => `GPU ${g.index} ${f(g)}`).join(" · ");
const multi = (hw.gpus || []).length > 1;
@@ -353,7 +365,7 @@ $("req-all").addEventListener("click", () => { reqShowAll = !reqShowAll; if (las
let mcpInfo = {servers: [], tools: 0}, mcpRetry = null;
async function loadMcp() {
try {
const r = await fetch("/mcp", {headers: headers()});
const r = await fetch("mcp", {headers: headers()});
if (!r.ok) return;
mcpInfo = await r.json();
} catch (e) { return; /* an older server: no MCP */ }
@@ -685,7 +697,7 @@ async function send() {
let firstAt = null, thinkStart = null, usage = null, frame = 0;
const paint = () => { frame = 0; updateAssistant(el, m, true); scrollDown(); };
try {
const r = await fetch("/v1/chat/completions", {method: "POST", headers: headers(true), body: JSON.stringify(body),
const r = await fetch("v1/chat/completions", {method: "POST", headers: headers(true), body: JSON.stringify(body),
signal: controller.signal});
if (!r.ok) {
let msg = `HTTP ${r.status}`;
@@ -887,7 +899,7 @@ function loadDrawer(s = settings) {
let sharedOn = false;
async function loadShared() {
try {
const r = await fetch("/settings", {headers: headers()});
const r = await fetch("settings", {headers: headers()});
if (r.ok) sharedOn = !!(await r.json()).shared;
} catch (e) { /* an older server: the switch just stays off */ }
$("s-share").setAttribute("aria-checked", String(sharedOn));
@@ -901,7 +913,7 @@ function sharedDefaults(s) {
return d;
}
async function saveShared(on, s) {
const r = await fetch("/settings", {method: "POST", headers: headers(true),
const r = await fetch("settings", {method: "POST", headers: headers(true),
body: JSON.stringify({defaults: on ? sharedDefaults(s) : null})});
if (!r.ok) {
let msg = `HTTP ${r.status}`;
+15 -15
View File
@@ -4,9 +4,9 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Strata</title>
<link rel="stylesheet" href="/web/tokens.css">
<link rel="stylesheet" href="/web/components.css">
<link rel="stylesheet" href="/web/app.css">
<link rel="stylesheet" href="web/tokens.css">
<link rel="stylesheet" href="web/components.css">
<link rel="stylesheet" href="web/app.css">
<script>
// the theme before the first paint: the saved choice, else the system's
try {
@@ -21,17 +21,17 @@
<span class="st-header__brand">Strata</span>
<nav class="st-tabs" role="tablist" aria-label="Views">
<button class="st-tab" role="tab" id="tab-btn-chat" aria-controls="view-chat" aria-selected="true" data-tab="chat">
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-chat"/></svg>Chat</button>
<svg class="st-icon st-icon--sm"><use href="web/sprite.svg#i-chat"/></svg>Chat</button>
<button class="st-tab" role="tab" id="tab-btn-monitor" aria-controls="view-monitor" aria-selected="false" data-tab="monitor">
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-activity"/></svg>Monitor</button>
<svg class="st-icon st-icon--sm"><use href="web/sprite.svg#i-activity"/></svg>Monitor</button>
<button class="st-tab" role="tab" id="tab-btn-about" aria-controls="view-about" aria-selected="false" data-tab="about">
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-info"/></svg>About</button>
<svg class="st-icon st-icon--sm"><use href="web/sprite.svg#i-info"/></svg>About</button>
</nav>
<span class="st-header__spacer"></span>
<span class="st-pill" id="pill" data-state="idle" role="status" aria-live="polite">
<span class="st-pill__dot"></span><span id="pill-text">Connecting&hellip;</span></span>
<button class="st-btn st-btn--icon" id="theme-btn" title="Light / dark" aria-label="Switch between light and dark">
<svg class="st-icon"><use id="theme-icon" href="/web/sprite.svg#i-moon"/></svg></button>
<svg class="st-icon"><use id="theme-icon" href="web/sprite.svg#i-moon"/></svg></button>
</header>
<main>
@@ -51,20 +51,20 @@
<textarea id="input" rows="2" placeholder="Ask anything&hellip;" aria-label="Message"></textarea>
<div class="st-composer__bar">
<button type="button" class="st-btn st-btn--icon" id="attach-btn" title="Attach a text file (or drop it here)" aria-label="Attach a file">
<svg class="st-icon"><use href="/web/sprite.svg#i-attach"/></svg></button>
<svg class="st-icon"><use href="web/sprite.svg#i-attach"/></svg></button>
<input type="file" id="file" multiple hidden>
<button type="button" class="st-btn st-btn--icon" id="new-btn" title="New chat" aria-label="New chat">
<svg class="st-icon"><use href="/web/sprite.svg#i-new-chat"/></svg></button>
<svg class="st-icon"><use href="web/sprite.svg#i-new-chat"/></svg></button>
<button type="button" class="st-btn st-btn--icon" id="export-btn" title="Save this chat as Markdown" aria-label="Save this chat">
<svg class="st-icon"><use href="/web/sprite.svg#i-download"/></svg></button>
<svg class="st-icon"><use href="web/sprite.svg#i-download"/></svg></button>
<button type="button" class="st-btn st-btn--icon" id="sampling-btn" title="Sampling and thinking" aria-label="Sampling and thinking">
<svg class="st-icon"><use href="/web/sprite.svg#i-settings"/></svg></button>
<svg class="st-icon"><use href="web/sprite.svg#i-settings"/></svg></button>
<span class="spacer"></span>
<span class="composer-hint" id="composer-hint"></span>
<button type="button" class="st-btn st-btn--secondary" id="stop-btn" hidden>
<svg class="st-icon st-icon--sm"><use href="/web/sprite.svg#i-stop"/></svg>Stop</button>
<svg class="st-icon st-icon--sm"><use href="web/sprite.svg#i-stop"/></svg>Stop</button>
<button type="submit" class="st-btn st-btn--primary st-btn--send" id="send-btn" aria-label="Send">
<svg class="st-icon"><use href="/web/sprite.svg#i-send"/></svg></button>
<svg class="st-icon"><use href="web/sprite.svg#i-send"/></svg></button>
</div>
</form>
</div>
@@ -169,7 +169,7 @@
<aside class="st-drawer" id="drawer" data-open="false" aria-label="Sampling and thinking" aria-hidden="true">
<div class="st-drawer__head"><span class="card-title">Sampling</span>
<button class="st-btn st-btn--icon" id="drawer-close" aria-label="Close">
<svg class="st-icon"><use href="/web/sprite.svg#i-chevron"/></svg></button></div>
<svg class="st-icon"><use href="web/sprite.svg#i-chevron"/></svg></button></div>
<div class="st-drawer__body">
<div class="st-field">
<label class="st-label" for="s-thinking">Thinking <output id="o-thinking"></output></label>
@@ -218,6 +218,6 @@
</aside>
<div class="scrim" id="scrim" hidden></div>
<div class="st-toasts" id="toasts" aria-live="polite"></div>
<script src="/web/app.js"></script>
<script src="web/app.js"></script>
</body>
</html>
+2 -2
View File
@@ -3,8 +3,8 @@
Values marked (derived) are not in the Gainsty source and were added for the app. */
/* Outfit (SIL Open Font License 1.1, serve/web/fonts/OFL.txt): one variable font, weights 100-900 */
@font-face{font-family:"Outfit";src:url("/fonts/outfit-latin-wght.woff2") format("woff2-variations");font-weight:100 900;font-display:swap;unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:"Outfit";src:url("/fonts/outfit-latin-ext-wght.woff2") format("woff2-variations");font-weight:100 900;font-display:swap;unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:"Outfit";src:url("../fonts/outfit-latin-wght.woff2") format("woff2-variations");font-weight:100 900;font-display:swap;unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:"Outfit";src:url("../fonts/outfit-latin-ext-wght.woff2") format("woff2-variations");font-weight:100 900;font-display:swap;unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
:root{
/* type */
+74
View File
@@ -0,0 +1,74 @@
"""serve/winjob.py - on Windows, tie the processes Strata starts to the server's own lifetime.
Closing the console window, Task Manager or a crash end the server without running its cleanup, and the engine,
the vision encoder and the MCP servers kept running on their own. `contain(proc)` puts a child in a job object that
is set to kill everything in it when its last handle closes - and the only handle is this process's, which the OS
closes however the server ends. Processes the child starts later join the same job. The server itself stays out of
the job, so a browser that `--open` starts is not tied to it. Elsewhere, and if the job cannot be made, a no-op.
"""
from __future__ import annotations
import os
import threading
_job = None
_lock = threading.Lock()
if os.name == "nt":
import ctypes
from ctypes import wintypes
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
class _IoCounters(ctypes.Structure):
_fields_ = [(n, ctypes.c_ulonglong) for n in ("ReadOperationCount", "WriteOperationCount",
"OtherOperationCount", "ReadTransferCount",
"WriteTransferCount", "OtherTransferCount")]
class _BasicLimits(ctypes.Structure):
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong), ("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD)]
class _ExtendedLimits(ctypes.Structure):
_fields_ = [("BasicLimitInformation", _BasicLimits), ("IoInfo", _IoCounters),
("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t)]
_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
_JobObjectExtendedLimitInformation = 9
_k32.CreateJobObjectW.restype = wintypes.HANDLE
_k32.CreateJobObjectW.argtypes = (wintypes.LPVOID, wintypes.LPCWSTR)
_k32.SetInformationJobObject.argtypes = (wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD)
_k32.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
def _make_job():
job = _k32.CreateJobObjectW(None, None) # no security attributes: the handle is not inherited
if not job:
return None
info = _ExtendedLimits()
info.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if not _k32.SetInformationJobObject(job, _JobObjectExtendedLimitInformation, ctypes.byref(info),
ctypes.sizeof(info)):
_k32.CloseHandle(job)
return None
return job
def contain(proc) -> bool:
"""Put a started subprocess.Popen in the kill-on-close job. True if it is in; False (harmlessly) otherwise."""
global _job
if os.name != "nt" or proc is None:
return False
with _lock:
if _job is None:
_job = _make_job() or 0
if not _job:
return False
try:
return bool(_k32.AssignProcessToJobObject(_job, int(proc._handle)))
except (AttributeError, OSError, ValueError):
return False
+707 -73
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Strata for Linux: the first run installs everything and starts the model; later runs just start it.
# Needs only an NVIDIA driver. Python (with venv) is installed through apt/dnf if it is missing (asks for sudo).
# Needs only an NVIDIA driver (or, experimental: an AMD RX 7900 XT/XTX with the 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
# python3-venv package), and a venv made without it has no pip
+93
View File
@@ -0,0 +1,93 @@
// src/artifact/gguf_reader_test.cpp - the reader refuses what it cannot arbitrate (synthetic GGUF, no model,
// no GPU).
//
// 1. two tensors with distinct names: the file opens and find() sees both;
// 2. the same name twice: refused at open, and the error names the tensor and the file - GGUF has no
// index to say which of the two a name means, and find() is first-match, so the alternative is a
// lookup that silently picks by position.
//
// The fixture is a minimal GGUF v3 written here (header, no metadata, two F32[8] tensors, 32-byte
// alignment), so the test needs neither gguf-py nor a shard. The file is closed before it is removed:
// on Windows an open mapping keeps it.
#include "strata/artifact/gguf_reader.hpp"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace {
int g_fail = 0;
void check(bool ok, const char* what) {
std::printf(" %-66s %s\n", what, ok ? "ok" : "FAIL");
if (!ok) ++g_fail;
}
template <class T> void put(std::vector<uint8_t>& b, T v) {
const auto n = b.size();
b.resize(n + sizeof(T));
std::memcpy(b.data() + n, &v, sizeof(T));
}
void put_str(std::vector<uint8_t>& b, const std::string& s) {
put<uint64_t>(b, s.size());
b.insert(b.end(), s.begin(), s.end());
}
// A GGUF v3 file of F32[8] tensors named `names`, laid out 32 bytes apart from a 32-byte-aligned data start.
std::filesystem::path write_gguf(const std::vector<std::string>& names) {
std::vector<uint8_t> b;
put<uint32_t>(b, 0x46554747u); // "GGUF"
put<uint32_t>(b, 3);
put<uint64_t>(b, names.size()); // n_tensors
put<uint64_t>(b, 0); // n_kv
for (size_t i = 0; i < names.size(); ++i) {
put_str(b, names[i]);
put<uint32_t>(b, 1); // n_dims
put<uint64_t>(b, 8);
put<uint32_t>(b, 0); // F32
put<uint64_t>(b, 32 * i); // offset from data_start
}
b.resize((b.size() + 31) / 32 * 32 + 32 * names.size(), 0);
const auto path = std::filesystem::temp_directory_path() / "strata_gguf_reader_test.gguf";
std::ofstream(path, std::ios::binary).write(reinterpret_cast<const char*>(b.data()), (std::streamsize)b.size());
return path;
}
} // namespace
int main() {
std::printf("gguf_reader_test\n");
{
const auto path = write_gguf({"blk.0.attn_q.weight", "blk.0.attn_k.weight"});
std::string err;
size_t n = 0;
bool both = false;
try {
strata::GgufFile g(path.string());
n = g.tensors().size();
both = g.find("blk.0.attn_q.weight") && g.find("blk.0.attn_k.weight");
} catch (const std::exception& e) {
err = e.what();
}
std::filesystem::remove(path);
check(err.empty() && n == 2 && both, "distinct names: the file opens and find() sees both tensors");
}
{
const auto path = write_gguf({"blk.0.attn_q.weight", "blk.0.attn_q.weight"});
std::string err;
try {
strata::GgufFile g(path.string());
} catch (const std::exception& e) {
err = e.what();
}
std::filesystem::remove(path);
check(!err.empty(), "the same name twice: refused at open");
check(err.find("blk.0.attn_q.weight") != std::string::npos, " the error names the tensor");
check(err.find(path.filename().string()) != std::string::npos, " the error names the file");
if (!err.empty()) std::printf(" (%s)\n", err.c_str());
}
std::printf(g_fail ? "gguf_reader_test: %d FAILED\n" : "gguf_reader_test: all passed\n", g_fail);
return g_fail ? 1 : 0;
}
+152
View File
@@ -0,0 +1,152 @@
#include "strata/core/conversation_cache.hpp"
#include <cstdio>
#include <cstdlib>
using namespace strata::core;
namespace {
int checks = 0;
void check(bool value, const char* description) {
++checks;
if (!value) { std::fprintf(stderr, "FAIL: %s\n", description); std::exit(1); }
}
SavedConversation image(std::initializer_list<int32_t> ids, bool cvec = true) {
SavedConversation s;
s.live.ids = ids;
s.live.gdn.resize(64, 7);
s.cvec = cvec;
return s;
}
}
int main() {
{
ConversationBuffer bytes;
const size_t first = ConversationBuffer::segment_bytes + 17;
const size_t peak = bytes.allocation_peak(first);
bytes.resize(first, 7);
check(bytes.bytes() <= peak, "segmented payload and directory fit admitted bytes");
uint8_t* original = nullptr;
bytes.visit(0, 1, [&](uint8_t* p, size_t, size_t) { original = p; return true; });
const size_t grown_peak = bytes.allocation_peak(first + 99);
bytes.resize(first + 99, 9);
bytes.visit(0, 1, [&](uint8_t* p, size_t, size_t) {
check(p == original, "appending preserves existing payload addresses"); return true;
});
check(bytes.bytes() <= grown_peak, "growth including transient directory fits admission");
std::vector<uint8_t> tail(116);
check(bytes.read(tail.data(), ConversationBuffer::segment_bytes, tail.size()), "read spans segment boundaries");
check(std::all_of(tail.begin(), tail.begin()+17, [](auto v){return v==7;}) &&
std::all_of(tail.begin()+17, tail.end(), [](auto v){return v==9;}), "growth preserves prefix and initializes only suffix");
auto copy = bytes;
bytes.resize(ConversationBuffer::segment_bytes + 10);
bytes.resize(first, 7); bytes.resize(first + 99, 9);
check(bytes == copy, "equality ignores differing segmentation after rewind and regrowth");
check(!bytes.read(tail.data(), bytes.size()-1, 2), "range check rejects a truncated payload");
check(bytes.allocation_peak(SIZE_MAX) == SIZE_MAX, "allocation estimate rejects overflow");
}
{
ConversationBuffer bytes;
for (size_t n=1;n<=4096;++n) {
const size_t peak = bytes.allocation_peak(n*17);
bytes.resize(n*17,7);
check(bytes.bytes() <= peak,"small append stays within the predicted allocation peak");
}
size_t segments = 0;
bytes.visit(0,bytes.size(),[&](const uint8_t*,size_t,size_t){++segments;return true;});
check(segments <= 4,"thousands of small turns do not create thousands of restore transfers");
}
{
ConversationKv layer;
layer.k.resize(400);
std::vector<ConversationKv> layers;
layers.push_back(std::move(layer));
const size_t retained = layers.capacity()*sizeof(ConversationKv) + layers[0].bytes();
const size_t parked = image({1,2,3}).bytes();
ConversationCache cache(retained + parked, 4);
check(cache.put(image({1,2,3})), "park before retaining active storage");
cache.retain(std::move(layers), 16);
check(cache.bytes() == retained + parked && cache.size() == 1, "active retained storage consumes bytes but no parked slot");
cache.limit_reuse(9); cache.limit_reuse(12);
auto reuse = cache.take_reuse();
check(reuse.unchanged_tokens == 9, "a later continuation cannot undo a rewind's dirty boundary");
check(cache.bytes() == parked, "taking retained buffers releases their budget accounting");
cache.retain(std::move(reuse.kv), 16);
check(!cache.can_fit(parked),"retained storage is counted when checking a capture without eviction");
check(cache.retained_bytes() == retained && cache.size() == 1 && cache.evictions() == 0,
"optional reuse admission does not evict or release anything");
check(!cache.can_fit(SIZE_MAX) && !cache.can_fit(1,SIZE_MAX),"non-mutating reservation rejects overflow");
check(cache.make_room(parked), "reservation can discard optional active buffers");
check(cache.retained_bytes() == 0 && cache.size() == 1 && cache.evictions() == 0,
"pressure drops retained storage before evicting parked conversations");
}
const std::vector<int64_t> a = {1, 2, 3, 4}, b = {9, 8, 7, 6};
{
ConversationCache cache(1024, 2);
check(cache.put(image({1, 2, 3})), "park A");
check(cache.put(image({9, 8, 7})), "park B");
auto match = cache.best(a, {}, true);
check(match.tokens == 3 && match.live, "A/B/A: recover A");
auto restored = cache.take(match.index);
check(restored.live.ids == std::vector<int32_t>({1, 2, 3}), "taking selected A preserves identity");
check(cache.size() == 1 && cache.best(b, {}, true).tokens == 3, "B remains parked");
check(cache.bytes() == image({9,8,7}).bytes(), "byte accounting after take");
check(cache.put(std::move(restored)), "park returned A as newest");
check(cache.put(image({5, 6})), "evict oldest by slot limit");
check(cache.best(b, {}, true).tokens == 0 && cache.best(a, {}, true).tokens == 3, "B evicted before A");
check(cache.evictions() == 1, "eviction counter");
}
{
auto s = image({1, 2, 3});
ConversationCheckpoint cp;
cp.ids = {1, 2};
s.checkpoints.push_back(cp);
ConversationCache cache(4096, 4);
cache.put(std::move(s));
auto match = cache.best(std::vector<int64_t>{1, 2, 9, 4}, {}, true);
check(match.tokens == 2 && !match.live, "edited suffix falls back to parked checkpoint");
check(cache.best(a, {}, true).tokens == 3, "live prefix beats shorter checkpoint");
check(cache.best(a, {}, false).tokens == 0, "steering mode is isolated");
check(cache.best(std::vector<int64_t>{1, 2}, {}, true).tokens == 0, "equal-length checkpoint cannot consume last token");
check(cache.best(std::vector<int64_t>{1}, {}, true).tokens == 0, "short prompt cannot match");
check(cache.best(std::vector<int64_t>{}, {}, true).tokens == 0, "empty prompt cannot match");
cache.put(image({1, 2, 3}));
check(cache.best(a, {}, true).index == 1, "ties prefer most recently parked");
}
{
auto s = image({1, 2, 3});
s.live.imgs = {{1, 123}};
ConversationCache cache(1024, 3);
cache.put(std::move(s));
check(cache.best(a, {{1,123}}, true).tokens == 3, "same image can resume");
check(cache.best(a, {{1,124}}, true).tokens == 0, "different image pixels invalidate same pad tokens");
check(cache.best(a, {}, true).tokens == 0, "missing image invalidates prefix");
check(cache.best(a, {{1,123},{2,45}}, true).tokens == 0, "additional image in cached prefix invalidates");
check(cache.best(a, {{1,123},{3,45}}, true).tokens == 3, "image after cached prefix does not invalidate");
}
{
const size_t one = image({1,2,3}).bytes();
ConversationCache cache(one*2, 8);
cache.put(image({1,2,3})); cache.put(image({9,8,7}));
check(cache.bytes() == one*2, "budget holds two exact-sized images");
auto held = cache.take(cache.best(a, {}, true).index);
check(cache.make_room(one, held.bytes()), "count in-flight image when reserving outgoing snapshot");
check(cache.size() == 0, "in-flight reservation evicts otherwise fitting B");
check(cache.put(image({5,6,7}), held.bytes()), "insert with in-flight accounting");
check(cache.bytes()+held.bytes() <= one*2, "exchange obeys byte budget");
check(!cache.make_room(one+1, one), "oversized exchange rejected");
check(cache.size() == 1, "oversized snapshot does not evict useful entries");
auto huge = image({1}); huge.live.gdn.resize(one*3);
check(!cache.put(std::move(huge)), "oversized image rejected");
check(cache.size() == 1, "oversized put leaves cache unchanged");
check(!cache.make_room(0, one*2+1), "held larger than budget cannot underflow");
}
{
ConversationCache disabled(0,4), no_slots(1024,0);
check(!disabled.enabled() && !no_slots.enabled(), "both disable switches");
check(!disabled.put(image({1,2,3})) && !no_slots.put(image({1,2,3})), "disabled cache stores nothing");
check(disabled.best(a,{},true).tokens == 0, "disabled cache has no matches");
}
std::printf("conversation_cache_test: %d checks passed\n", checks);
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <initializer_list>
#include <limits>
namespace strata::core::conversation_detail {
inline bool add(size_t& total, size_t n) {
if (n > std::numeric_limits<size_t>::max() - total) return false;
total += n;
return true;
}
inline bool product(size_t& out, std::initializer_list<uint64_t> factors) {
size_t value = 1;
for (uint64_t factor : factors) {
if (factor > std::numeric_limits<size_t>::max() ||
(factor && value > std::numeric_limits<size_t>::max() / factor)) return false;
value *= (size_t) factor;
}
out = value;
return true;
}
} // namespace strata::core::conversation_detail
+51
View File
@@ -0,0 +1,51 @@
#include "strata/core/conversation_memory.hpp"
#include <charconv>
#include <fstream>
#include <limits>
#include <sstream>
#include <string>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace strata::core {
std::optional<uint64_t> conversation_mem_available(std::istream& meminfo) {
std::optional<uint64_t> result;
std::string line;
while (std::getline(meminfo, line)) {
std::istringstream fields(line);
std::string key, value, unit, extra;
if (!(fields >> key) || key != "MemAvailable:") continue;
if (result || !(fields >> value >> unit) || unit != "kB" || (fields >> extra)) return {};
uint64_t kb = 0;
const auto parsed = std::from_chars(value.data(), value.data() + value.size(), kb);
if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() ||
kb > std::numeric_limits<uint64_t>::max() / 1024) return {};
result = kb * 1024;
}
if (meminfo.bad() || (meminfo.fail() && !meminfo.eof())) return {};
return result;
}
std::optional<uint64_t> conversation_available_memory() {
#if defined(_WIN32)
MEMORYSTATUSEX status{};
status.dwLength = sizeof status;
if (GlobalMemoryStatusEx(&status)) return status.ullAvailPhys;
return {};
#elif defined(__linux__)
std::ifstream meminfo("/proc/meminfo");
if (!meminfo) return {};
return conversation_mem_available(meminfo);
#else
return {};
#endif
}
} // namespace strata::core
+66
View File
@@ -0,0 +1,66 @@
#include "strata/core/conversation_memory.hpp"
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <sstream>
#if defined(_WIN32)
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
using namespace strata::core;
namespace {
int checks = 0;
void check(bool ok, const char* label) {
++checks;
if (!ok) { std::fprintf(stderr, "FAIL: %s\n", label); std::exit(1); }
}
}
int main() {
for (const char* text : {"", "MemFree: 100 kB\n", "MemAvailable: -1 kB\n",
"MemAvailable: +1 kB\n", "MemAvailable: 1 MB\n",
"MemAvailable: 1\n", "MemAvailable: 1x kB\n",
"MemAvailable: 18446744073709551615 kB\n",
"MemAvailable: 18446744073709551616 kB\n",
"MemAvailable: 1 kB trailing\n",
"MemAvailable: 1 kB\nMemAvailable: 2 kB\n"}) {
std::istringstream input(text);
check(!conversation_mem_available(input), "missing/malformed telemetry is unknown");
}
std::istringstream normal("MemTotal: 999999 kB\nMemAvailable: 12345 kB\nSwapFree: 777 kB\n");
check(conversation_mem_available(normal) == 12345ULL * 1024, "only MemAvailable is counted");
std::istringstream zero("MemAvailable: 0 kB");
check(conversation_mem_available(zero) == 0, "zero available memory is known");
std::istringstream broken("MemAvailable: 123 kB\n");
broken.setstate(std::ios::badbit);
check(!conversation_mem_available(broken), "I/O failure declines admission");
check(!conversation_memory_admit({}, 0, 0), "unknown fails closed even with zero floor");
check(conversation_memory_admit(100, 40, 60), "exact allocation plus floor fits");
check(!conversation_memory_admit(99, 40, 60), "one byte below required memory rejected");
check(!conversation_memory_admit(59, 0, 60), "floor subtraction cannot underflow");
check(conversation_memory_admit(60, 0, 60), "post-capture floor check");
check(!conversation_memory_admit(100, std::numeric_limits<uint64_t>::max(), 1), "allocation arithmetic cannot overflow");
check(conversation_memory_admit(std::numeric_limits<uint64_t>::max(),
std::numeric_limits<uint64_t>::max(), 0), "maximal exact bound");
check(!conversation_memory_admit(std::numeric_limits<uint64_t>::max(),
std::numeric_limits<uint64_t>::max(), 1), "maximal sum overflow rejected");
// Test the real provider without assuming any particular amount of free RAM.
const auto available = conversation_available_memory();
check(!available || conversation_memory_admit(available, 0, 0), "provider returns bytes or unknown");
#if defined(_WIN32)
// Unknown must fail closed in production, but must not let a broken Windows provider pass this test.
// Compare with total physical memory, not a second available reading: other processes can allocate
// between calls. No pressure allocation or fixed free-memory assumption is needed.
MEMORYSTATUSEX physical{};
physical.dwLength = sizeof physical;
check(GlobalMemoryStatusEx(&physical) != 0, "Windows physical-memory API is available");
check(available.has_value() && *available <= physical.ullTotalPhys,
"Windows provider returns a known, physically bounded sample");
#endif
std::printf("conversation_memory_test: %d checks passed\n", checks);
}
+264
View File
@@ -0,0 +1,264 @@
#include "strata/core/conversation_snapshot.hpp"
#include "strata/kernels/kv_q4.hpp"
#include "conversation_checked.hpp"
#include <cuda_runtime.h>
#include <array>
#include <cstring>
#include <limits>
namespace strata::core {
namespace {
struct Layout {
int format;
int64_t cells, pooled_rows, page_size;
size_t data, scales, pooled, value_data, value_scales;
};
bool valid_extent(const QsaState& st, int64_t upto, std::string& error) {
const int64_t page = strata::kernels::qsa_real_shapes().page_size;
if (upto < 0 || upto > st.max_cells || upto > std::numeric_limits<int64_t>::max() - (page - 1)) {
error = "conversation snapshot: invalid K/V extent";
return false;
}
return true;
}
bool layout(const QsaState& st, const ModelGeometry& g, int64_t upto, bool index, Layout& l, std::string& error) {
if (!valid_extent(st, upto, error)) return false;
if (st.kv_hybrid && (st.kv_mode != 0 || st.kv_q4 || st.kv_int8)) {
error = "conversation snapshot: hybrid K8V4 requires an identity layout and distinct format flags";
return false;
}
const auto s = strata::kernels::qsa_real_shapes();
const bool int8_keys = st.kv_int8 || st.kv_hybrid;
if (g.n_head_kv <= 0 || g.head_dim <= 0 || g.head_dim > INT32_MAX || g.idx_key_dim <= 0 ||
(st.kv_q4 && g.head_dim % 32) || (int8_keys && !st.kv_q4 && g.head_dim % 64)) {
error = "conversation snapshot: invalid K/V geometry";
return false;
}
const int64_t cells = ((upto + s.page_size - 1) / s.page_size) * s.page_size;
const size_t per = st.kv_q4 ? (size_t) strata::kernels::kv_q4_bytes_per_head((int) g.head_dim)
: (size_t) g.head_dim * (int8_keys ? 1 : 2);
// 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};
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,
int8_keys && !st.kv_q4 ? (uint64_t) (g.head_dim / 64) * 2 : 0}) ||
!product(l.pooled, {(uint64_t) pooled, (uint64_t) g.idx_key_dim, sizeof(float)})) {
error = "conversation snapshot: K/V byte count overflow";
return false;
}
l.value_data = l.data;
l.value_scales = l.scales;
if (st.kv_hybrid) {
if (!product(l.value_data, {(uint64_t) cells, (uint64_t) g.n_head_kv,
(uint64_t) strata::kernels::kv_q4_bytes_per_head((int) g.head_dim)})) {
error = "conversation snapshot: hybrid V byte count overflow";
return false;
}
l.value_scales = 0;
}
return true;
}
std::array<void*, 5> pools(const QsaState& st, bool resident = false) {
const bool host = st.kv_mode != 0 && !resident;
if (st.kv_hybrid) return {st.k_q, st.v_q4, st.k_scale, nullptr, st.idx_pooled};
if (st.kv_q4)
return {host ? st.host.k_q4 : st.k_q4, host ? st.host.v_q4 : st.v_q4, nullptr, nullptr, st.idx_pooled};
if (st.kv_int8)
return {host ? st.host.k_q : st.k_q, host ? st.host.v_q : st.v_q,
host ? st.host.k_scale : st.k_scale, host ? st.host.v_scale : st.v_scale, st.idx_pooled};
return {host ? st.host.k_pool : st.k_pool, host ? st.host.v_pool : st.v_pool, nullptr, nullptr, st.idx_pooled};
}
bool valid(const QsaState& st, const Layout& l, int64_t upto, std::string& error) {
if (upto < 0 || upto > st.max_cells || st.n_pages < 0 || l.cells / l.page_size > st.n_pages ||
st.kv_mode < 0 || st.kv_mode > 2 || st.n_slots <= 0 ||
(st.kv_mode == 0 && st.n_slots < st.n_pages) ||
l.pooled_rows > st.idx_pooled_rows || (st.kv_mode != 0 && !st.host.present())) {
error = "conversation snapshot: invalid K/V extent or missing authoritative host pool";
return false;
}
if (st.kv_mode == 1) {
const auto& m = st.map;
if (m.n_blocks != st.n_pages || m.n_slots != st.n_slots || !m.page_table || !m.slot_block ||
!m.slot_stamp || !m.slot_ref || !m.ctl || !m.miss_block || !m.miss_slot) {
error = "conversation snapshot: invalid streaming map";
return false;
}
}
if (st.kv_mode == 2 && (!st.page_table ||
(st.kv_q4 ? !st.k_q4 || !st.v_q4 : st.kv_int8 ? !st.k_q || !st.v_q || !st.k_scale || !st.v_scale
: !st.k_pool || !st.v_pool))) {
error = "conversation snapshot: invalid draft ring";
return false;
}
return true;
}
bool transfer(void* dst, const void* src, size_t n, std::string& error) {
if (!n) return true;
if (!src || !dst) { error = "conversation snapshot: missing state buffer"; return false; }
// Default handles both device allocations and device-mapped host pool aliases.
const cudaError_t e = cudaMemcpy(dst, src, n, cudaMemcpyDefault);
if (e == cudaSuccess) return true;
error = std::string("conversation snapshot copy: ") + cudaGetErrorString(e);
return false;
}
} // namespace
bool conversation_kv_capture_bytes(const ConversationKv& image, const QsaState& st, const ModelGeometry& g,
int64_t upto, bool index, size_t& bytes, std::string& error) {
Layout l{};
if (!layout(st, g, upto, index, l, error) || !valid(st, l, upto, error)) return false;
const std::array<size_t,5> sizes = {l.data, l.value_data, l.scales, l.value_scales, l.pooled};
const std::array<const ConversationBuffer*,5> buffers = {&image.k, &image.v, &image.k_scale, &image.v_scale, &image.pooled};
bytes = 0;
for (size_t i = 0; i < buffers.size(); ++i) {
const size_t n = buffers[i]->allocation_peak(sizes[i]);
if (n == SIZE_MAX || !conversation_detail::add(bytes, n)) {
error = "conversation snapshot: segmented K/V allocation overflow";
return false;
}
}
return true;
}
size_t conversation_kv_bytes(const QsaState& st, const ModelGeometry& g, int64_t upto, bool index) {
size_t bytes = 0;
std::string error;
return conversation_kv_capture_bytes({}, st, g, upto, index, bytes, error) ? bytes : 0;
}
bool conversation_kv_save(ConversationKv& image, const QsaState& st, const ModelGeometry& g,
int64_t upto, bool index, std::string& error,
int64_t unchanged_tokens, size_t* reused_bytes) {
Layout l{};
if (!layout(st, g, upto, index, l, error)) return false;
if (!valid(st, l, upto, error)) return false;
if (unchanged_tokens < 0 || unchanged_tokens > upto || unchanged_tokens > image.cells) {
error = "conversation snapshot: invalid unchanged prefix";
return false;
}
const int64_t whole_cells = (unchanged_tokens / l.page_size) * l.page_size;
image.format = l.format;
image.cells = l.cells;
image.heads = g.n_head_kv;
image.head_dim = g.head_dim;
image.page_size = l.page_size;
image.pooled_rows = l.pooled_rows;
image.idx_dim = g.idx_key_dim;
const auto src = pools(st);
const std::array<size_t,5> sizes = {l.data, l.value_data, l.scales, l.value_scales, l.pooled};
const std::array<ConversationBuffer*,5> dst = {&image.k, &image.v, &image.k_scale, &image.v_scale, &image.pooled};
for (size_t i = 0; i < dst.size(); ++i) {
// Recopy the partial page and the indexer's moving spare row. Completed
// pages/rows strictly before the first rewritten token remain identical.
const size_t keep = i == 4 ? (index ? size_t(unchanged_tokens / strata::kernels::qsa_real_shapes().idx_block) * g.idx_key_dim * 4 : 0)
: l.cells ? (sizes[i] / size_t(l.cells)) * size_t(whole_cells) : 0;
if (keep > dst[i]->size()) { error = "conversation snapshot: missing reusable prefix"; return false; }
dst[i]->resize(sizes[i]);
if (!dst[i]->visit(keep, sizes[i] - keep, [&](uint8_t* p, size_t n, size_t at) {
return transfer(p, src[i] ? static_cast<const uint8_t*>(src[i]) + at : nullptr, n, error);
})) return false;
if (reused_bytes) *reused_bytes += keep;
}
return true;
}
bool conversation_kv_validate(const ConversationKv& image, const QsaState& st, const ModelGeometry& g,
int64_t upto, bool index, std::string& error) {
Layout l{};
if (!layout(st, g, upto, index, l, error)) return false;
if (!valid(st, l, upto, error)) return false;
const std::array<size_t,5> sizes = {l.data, l.value_data, l.scales, l.value_scales, l.pooled};
const std::array<const ConversationBuffer*,5> src = {&image.k, &image.v, &image.k_scale, &image.v_scale, &image.pooled};
if (image.format != l.format || image.cells != l.cells || image.heads != g.n_head_kv ||
image.head_dim != g.head_dim || image.page_size != l.page_size || image.pooled_rows != l.pooled_rows ||
image.idx_dim != g.idx_key_dim) {
error = "conversation snapshot: incompatible K/V geometry";
return false;
}
for (size_t i = 0; i < src.size(); ++i)
if (src[i]->size() != sizes[i]) { error = "conversation snapshot: invalid K/V payload size"; return false; }
const auto dst = pools(st);
for (size_t i = 0; i < src.size(); ++i)
if (sizes[i] && !dst[i]) { error = "conversation snapshot: missing target state buffer"; return false; }
return true;
}
bool conversation_kv_restore(const ConversationKv& image, const QsaState& st, const ModelGeometry& g,
int64_t upto, bool index, std::string& error) {
if (!conversation_kv_validate(image, st, g, upto, index, error)) return false;
const std::array<const ConversationBuffer*,5> src = {&image.k, &image.v, &image.k_scale, &image.v_scale, &image.pooled};
const auto dst = pools(st);
for (size_t i = 0; i < src.size(); ++i)
if (!src[i]->visit(0, src[i]->size(), [&](const uint8_t* p, size_t n, size_t at) {
return transfer(static_cast<uint8_t*>(dst[i]) + at, p, n, error);
})) return false;
// VRAM slots still contain the outgoing conversation. Resolve must refill
// them from the restored authoritative pools before any attention reads.
if (st.kv_mode == 1) strata::kernels::kv_stream_reset(st.map, nullptr);
if (st.kv_mode == 2 && upto > 0) {
auto shapes = strata::kernels::qsa_real_shapes();
shapes.n_head_kv = g.n_head_kv; shapes.head_dim = g.head_dim;
const int64_t end = (upto + shapes.page_size - 1) / shapes.page_size;
strata::kernels::kv_ring_restore(qsa_attn_pools(st), st.host, qsa_kv_format(st),
std::max<int64_t>(0, end - st.n_slots), end, st.n_slots, shapes, nullptr);
}
const auto status = cudaGetLastError();
if (status == cudaSuccess) return true;
error = std::string("conversation snapshot residency restore: ") + cudaGetErrorString(status);
return false;
}
bool conversation_kv_verify(const ConversationKv& image, const QsaState& st, const ModelGeometry& g,
int64_t upto, bool index, uint64_t& fingerprint, std::string& error) {
if (!conversation_kv_validate(image, st, g, upto, index, error)) return false;
std::array<uint8_t, 65536> buffer;
uint64_t hash = 1469598103934665603ull;
auto compare = [&](const void* device, const uint8_t* expected, size_t bytes, bool include_hash) {
for (size_t offset = 0; offset < bytes;) {
const size_t n = std::min(buffer.size(), bytes - offset);
if (!transfer(buffer.data(), static_cast<const uint8_t*>(device) + offset, n, error)) return false;
if (std::memcmp(buffer.data(), expected + offset, n) != 0) {
error = "conversation snapshot: restored K/V bytes differ";
return false;
}
if (include_hash) for (size_t i = 0; i < n; ++i) { hash ^= buffer[i]; hash *= 1099511628211ull; }
offset += n;
}
return true;
};
const std::array<const ConversationBuffer*, 5> saved = {&image.k, &image.v, &image.k_scale, &image.v_scale, &image.pooled};
const auto authoritative = pools(st);
for (size_t i = 0; i < saved.size(); ++i)
if (!saved[i]->visit(0, saved[i]->size(), [&](const uint8_t* p, size_t n, size_t at) {
return compare(static_cast<const uint8_t*>(authoritative[i]) + at, p, n, true);
})) return false;
if (st.kv_mode == 2 && image.cells > 0) {
const auto resident = pools(st, true);
const int64_t end = image.cells / image.page_size;
const int64_t begin = std::max<int64_t>(0, end - st.n_slots);
for (size_t i = 0; i < 4; ++i) {
if (saved[i]->empty()) continue;
const size_t page_bytes = saved[i]->size() / size_t(end);
for (int64_t page = begin; page < end; ++page)
if (!saved[i]->visit(size_t(page) * page_bytes, page_bytes,
[&](const uint8_t* p, size_t n, size_t at) {
const size_t offset = size_t(page % st.n_slots) * page_bytes + at - size_t(page) * page_bytes;
return compare(static_cast<const uint8_t*>(resident[i]) + offset, p, n, false);
})) return false;
}
}
fingerprint = hash;
return true;
}
} // namespace strata::core
+272
View File
@@ -0,0 +1,272 @@
#include "strata/core/conversation_snapshot.hpp"
#include "strata/kernels/kv_q4.hpp"
#include <cuda_runtime.h>
#include <array>
#include <cstdio>
#include <cstdlib>
#include <limits>
#include <vector>
using namespace strata::core;
using namespace strata::kernels;
namespace {
int checks = 0;
void check(bool ok, const char* label) {
++checks;
if (!ok) { std::fprintf(stderr, "FAIL: %s\n", label); std::exit(1); }
}
void cuda_check(cudaError_t e) {
if (e != cudaSuccess) { std::fprintf(stderr, "CUDA: %s\n", cudaGetErrorString(e)); std::exit(1); }
}
struct Fixture {
ModelGeometry g;
QsaState state;
std::vector<void*> device, host;
std::array<void*,5> sources{};
std::array<size_t,5> sizes{};
template<class T> void alloc(T*& p, size_t n, bool pinned = false) {
if (!n) return;
void* raw = nullptr;
if (pinned) {
cuda_check(cudaHostAlloc(&raw, n, cudaHostAllocMapped));
host.push_back(raw);
void* mapped = nullptr;
cuda_check(cudaHostGetDevicePointer(&mapped, raw, 0));
p = static_cast<T*>(mapped);
} else {
cuda_check(cudaMalloc(&raw, n)); device.push_back(raw); p = static_cast<T*>(raw);
}
}
Fixture(int fmt, int mode) {
auto& st = state;
st.kv_mode = mode; st.kv_int8 = fmt==kKvInt8; st.kv_q4 = fmt==kKvQ4; st.kv_hybrid = fmt==3;
st.n_pages = 24; st.n_slots = mode ? 4 : st.n_pages;
st.max_cells = st.n_pages * 4; st.idx_pooled_rows = mode==2 ? 2 : st.max_cells/4+2;
const size_t per = fmt==kKvQ4 ? kv_q4_bytes_per_head((int)g.head_dim) : g.head_dim*((st.kv_int8 || st.kv_hybrid) ? 1:2);
const size_t rows = st.max_cells*g.n_head_kv, slot_rows = st.n_slots*4*g.n_head_kv;
sizes = {rows*per, rows*per, fmt==kKvInt8 ? rows*(g.head_dim/64)*2:0,
fmt==kKvInt8 ? rows*(g.head_dim/64)*2:0, (size_t)st.idx_pooled_rows*g.idx_key_dim*4};
if (fmt==3) {
sizes = {rows*per, rows*kv_q4_bytes_per_head((int)g.head_dim), rows*(g.head_dim/64)*2, 0,
(size_t)st.idx_pooled_rows*g.idx_key_dim*4};
alloc(st.k_q,sizes[0]); alloc(st.v_q4,sizes[1]); alloc(st.k_scale,sizes[2]);
sources[0]=st.k_q; sources[1]=st.v_q4; sources[2]=st.k_scale;
} else if (fmt==kKvQ4) {
alloc(st.k_q4,slot_rows*per); alloc(st.v_q4,slot_rows*per);
if (mode) {alloc(st.host.k_q4,sizes[0],true); alloc(st.host.v_q4,sizes[1],true);}
sources[0]=mode?st.host.k_q4:st.k_q4; sources[1]=mode?st.host.v_q4:st.v_q4;
} else if (fmt==kKvInt8) {
alloc(st.k_q,slot_rows*per); alloc(st.v_q,slot_rows*per);
alloc(st.k_scale,slot_rows*(g.head_dim/64)*2); alloc(st.v_scale,slot_rows*(g.head_dim/64)*2);
if (mode) {
alloc(st.host.k_q,sizes[0],true); alloc(st.host.v_q,sizes[1],true);
alloc(st.host.k_scale,sizes[2],true); alloc(st.host.v_scale,sizes[3],true);
}
sources[0]=mode?st.host.k_q:st.k_q; sources[1]=mode?st.host.v_q:st.v_q;
sources[2]=mode?st.host.k_scale:st.k_scale; sources[3]=mode?st.host.v_scale:st.v_scale;
} else {
alloc(st.k_pool,slot_rows*per); alloc(st.v_pool,slot_rows*per);
if (mode) {alloc(st.host.k_pool,sizes[0],true); alloc(st.host.v_pool,sizes[1],true);}
sources[0]=mode?st.host.k_pool:st.k_pool; sources[1]=mode?st.host.v_pool:st.v_pool;
}
alloc(st.idx_pooled,sizes[4]); sources[4]=st.idx_pooled;
alloc(st.page_table,st.n_pages*4);
if (mode==1) {
auto& m=st.map;
m.page_table=st.page_table; m.n_blocks=st.n_pages; m.n_slots=st.n_slots;
alloc(m.slot_block,st.n_slots*4); alloc(m.slot_stamp,st.n_slots*4); alloc(m.slot_ref,st.n_slots*4);
alloc(m.miss_block,st.n_slots*4); alloc(m.miss_slot,st.n_slots*4); alloc(m.ctl,kKvCtlInts*4);
} else if (mode==2) {
kv_ring_table(st.page_table,st.n_pages,st.n_slots,nullptr);
}
}
void fill(uint8_t salt) {
for (size_t i=0;i<sources.size();++i) {
std::vector<uint8_t> data(sizes[i]);
for (size_t j=0;j<data.size();++j) data[j]=(uint8_t)(salt+i*31+j*7+j/257);
if (!data.empty()) cuda_check(cudaMemcpy(sources[i],data.data(),data.size(),cudaMemcpyDefault));
}
}
void fill_after(uint8_t salt, int64_t first_dirty) {
for (size_t i=0;i<sources.size();++i) {
const size_t offset = i == 4 ? size_t(first_dirty/4)*g.idx_key_dim*4
: (sizes[i]/size_t(state.max_cells))*size_t((first_dirty/4)*4);
if (sizes[i] > offset)
cuda_check(cudaMemset(static_cast<uint8_t*>(sources[i])+offset, salt, sizes[i]-offset));
}
}
~Fixture() { for (void* p:device) cudaFree(p); for (void* p:host) cudaFreeHost(p); }
};
bool equal(const ConversationKv& a,const ConversationKv& b) {
return a.k==b.k && a.v==b.v && a.k_scale==b.k_scale && a.v_scale==b.v_scale && a.pooled==b.pooled;
}
void full_session(int fmt, int mode, int experts) {
Fixture main(fmt,mode), draft(fmt==3?kKvInt8:fmt,2);
auto& g=main.g;
g.n_layers=4; g.n_expert=experts;
g.ssm_state_size=2; g.ssm_v_heads=2; g.ssm_conv_channels=8;
SessionState ss;
ss.max_cells=96; ss.qsa_states=&main.state;
ss.layer_hi=g.n_layers; ss.gdn_alloc=g.n_gdn_layers(); ss.qsa_alloc=g.n_qsa_layers(); // the whole-model carve
std::string err;
ConversationStateSizes sizes;
check(conversation_state_sizes(g,sizes,err),"whole-session geometry sizes");
main.alloc(ss.gdn_state,sizes.gdn); main.alloc(ss.ple_hist,sizes.ple);
main.alloc(main.state.idx_tail,sizes.tail); main.alloc(main.state.idx_dead,sizes.dead);
main.alloc(main.state.idx_block_pos,sizes.block_pos);
std::vector<int32_t> ids(65);
for (size_t i=0;i<ids.size();++i) ids[i]=(int32_t)i+1;
std::vector<ConversationImageKey> images;
std::vector<ConversationCheckpoint> checkpoints;
auto fill=[&](uint8_t salt) {
main.fill(salt); draft.fill(salt);
for (const auto& [p,n] : std::vector<std::pair<void*,size_t>>{
{ss.gdn_state,sizes.gdn},{ss.ple_hist,sizes.ple},{main.state.idx_tail,sizes.tail},
{main.state.idx_dead,sizes.dead},{main.state.idx_block_pos,sizes.block_pos}})
cuda_check(cudaMemset(p,salt,n));
// A real indexer maintains its spare pooled row as a copy of idx_dead.
cuda_check(cudaMemcpy(main.state.idx_pooled+(ids.size()/4)*g.idx_key_dim,
main.state.idx_dead,sizes.dead,cudaMemcpyDeviceToDevice));
cuda_check(cudaDeviceSynchronize());
};
fill(13);
ConversationCheckpoint checkpoint;
checkpoint.ids.assign(ids.begin(),ids.begin()+3);
check(conversation_checkpoint_save(checkpoint,ss,g,err),"save complete running checkpoint");
checkpoint.used = 17;
checkpoints.push_back(std::move(checkpoint));
const ConversationView view{ids,images,checkpoints,true};
SavedConversation a,b,restored;
check(conversation_snapshot_save(a,view,ss,g,draft.state,err),"capture complete A");
check(a.checkpoints[0].used == 17,"upstream checkpoint LRU stamp survives capture");
fill(177);
check(conversation_snapshot_save(b,view,ss,g,draft.state,err),"capture complete B");
check(a.live.dead!=b.live.dead,"different opening-state spare keys in regression fixture");
auto bad=a; bad.kv.back().k.pop_back();
check(conversation_snapshot_restore(bad,ss,g,draft.state,err)==ConversationRestore::invalid,
"reject invalid late draft before any main-layer write");
check(conversation_snapshot_save(restored,view,ss,g,draft.state,err),"capture B after refused restore");
check(restored.live.gdn==b.live.gdn && restored.live.dead==b.live.dead &&
equal(restored.kv[0],b.kv[0]) && equal(restored.kv[1],b.kv[1]),"refusal preserves all tested state");
check(conversation_snapshot_restore(a,ss,g,draft.state,err)==ConversationRestore::restored,"restore complete A");
check(conversation_snapshot_save(restored,view,ss,g,draft.state,err),"capture restored A");
check(restored.live.gdn==a.live.gdn && restored.live.ple==a.live.ple && restored.live.tails==a.live.tails &&
restored.live.dead==a.live.dead && restored.live.block_pos==a.live.block_pos &&
equal(restored.kv[0],a.kv[0]) && equal(restored.kv[1],a.kv[1]),"whole-session A/B/A exactness including spare key");
check(ss.ple_prev[0]==64 && ss.ple_prev[1]==65,"PLE token window reconstructed");
for (int64_t dirty : {65, 3, 0}) {
check(conversation_snapshot_restore(a,ss,g,draft.state,err)==ConversationRestore::restored,"restore growth fixture base");
ConversationKvReuse reuse{a.kv,65,dirty};
const uint8_t* original = nullptr;
reuse.kv[0].k.visit(0,1,[&](const uint8_t* p,size_t,size_t){original=p;return true;});
main.fill_after(91,dirty); draft.fill_after(91,std::max<int64_t>(0,dirty-1));
cuda_check(cudaMemset(ss.gdn_state,91,sizes.gdn));
ids.resize(70);
for (size_t i=65;i<ids.size();++i) ids[i]=int32_t(i+1);
cuda_check(cudaMemcpy(main.state.idx_pooled+(ids.size()/4)*g.idx_key_dim,
main.state.idx_dead,sizes.dead,cudaMemcpyDeviceToDevice));
SavedConversation fresh,incremental;
check(conversation_snapshot_save(fresh,view,ss,g,draft.state,err),"full capture reference after growth or rewind");
size_t peak=0,reused=0;
check(conversation_snapshot_capture_bytes(reuse,view,ss,g,draft.state,peak,err),"admit incremental capture peak");
check(conversation_snapshot_save(incremental,view,ss,g,draft.state,err,std::move(reuse),&reused),"capture with retained pages");
check(incremental.bytes() <= peak,"incremental allocation stays within admitted bound");
check(incremental.live.gdn==fresh.live.gdn && incremental.live.ple==fresh.live.ple &&
incremental.live.dead==fresh.live.dead && equal(incremental.kv[0],fresh.kv[0]) &&
equal(incremental.kv[1],fresh.kv[1]),"incremental capture equals full capture after growth or rewind");
check((dirty>=4)==(reused>0),"only complete unchanged pages or rows are retained");
incremental.kv[0].k.visit(0,1,[&](const uint8_t* p,size_t,size_t){
check(p==original,"growth never reallocates the retained payload");return true;
});
check(conversation_snapshot_restore(incremental,ss,g,draft.state,err)==ConversationRestore::restored,"restore segmented incremental snapshot");
uint64_t fingerprint=0;
check(conversation_kv_verify(incremental.kv.back(),draft.state,g,70,false,fingerprint,err),"incremental draft authoritative and ring read-back");
ids.resize(65);
}
check(conversation_checkpoint_restore(a.checkpoints[0],ss,g,err),"restore early running checkpoint");
std::vector<uint8_t> spare(sizes.dead);
cuda_check(cudaMemcpy(spare.data(),main.state.idx_pooled,sizes.dead,cudaMemcpyDeviceToHost));
check(spare==a.checkpoints[0].dead,"checkpoint rebuilds spare row over a later completed block");
check(ss.ple_prev[0]==2 && ss.ple_prev[1]==3,"checkpoint PLE token window");
}
}
int main() {
int devices=0;
if (cudaGetDeviceCount(&devices)!=cudaSuccess || !devices) return 77;
for (int fmt : std::array<int,4>{kKvF16,kKvInt8,kKvQ4,3}) for (int mode : {0,1,2}) {
if (fmt==3 && mode!=0) continue;
Fixture f(fmt,mode);
if (fmt==3) {
std::string err;
ConversationKv invalid;
for (int unsupported : {1,2}) {
f.state.kv_mode=unsupported;
check(conversation_kv_bytes(f.state,f.g,9,true)==0,"hybrid streaming/ring rejected without block-mover abort");
check(!conversation_kv_save(invalid,f.state,f.g,9,true,err),"hybrid streaming/ring capture fails closed");
}
f.state.kv_mode=0;
}
{
std::string err;
ConversationKv invalid;
for (const int64_t bad : {-1LL, 97LL, (long long) std::numeric_limits<int64_t>::max()}) {
check(!conversation_kv_save(invalid,f.state,f.g,bad,mode!=2,err),"reject invalid save extent before allocation");
check(!conversation_kv_restore(invalid,f.state,f.g,bad,mode!=2,err),"reject invalid restore extent before copying");
check(conversation_kv_bytes(f.state,f.g,bad,mode!=2)==0,"invalid extent cannot overflow byte estimate");
}
}
for (int64_t upto : {0,1,3,4,5,63,64,65,96}) {
const bool index=mode!=2;
std::string err;
f.fill(13);
ConversationKv a,b,restored;
cuda_check(cudaDeviceSynchronize());
check(conversation_kv_save(a,f.state,f.g,upto,index,err),"save A");
check(a.bytes()==conversation_kv_bytes(f.state,f.g,upto,index),"size estimate equals snapshot payload");
f.fill(177);
check(conversation_kv_save(b,f.state,f.g,upto,index,err),"save B");
check(upto==0 || !equal(a,b),"fixture changes state");
check(conversation_kv_restore(a,f.state,f.g,upto,index,err),"restore A over B");
cuda_check(cudaDeviceSynchronize());
check(conversation_kv_save(restored,f.state,f.g,upto,index,err),"read back restored A");
check(equal(a,restored),"A/B/A byte-exact K/V and indexer state");
uint64_t fingerprint = 0;
check(conversation_kv_verify(a,f.state,f.g,upto,index,fingerprint,err),"verify restored authoritative and resident bytes without rewriting them");
if (mode==1) {
std::vector<int32_t> table((size_t)f.state.n_pages);
cuda_check(cudaMemcpy(table.data(),f.state.page_table,table.size()*4,cudaMemcpyDeviceToHost));
check(std::all_of(table.begin(),table.end(),[](int32_t x){return x==-1;}),"stale VRAM pages invalidated");
}
if (mode==2 && upto>0) {
const auto s=qsa_real_shapes();
const int64_t b1=(upto+s.page_size-1)/s.page_size,b0=std::max<int64_t>(0,b1-f.state.n_slots);
const size_t block=kv_block_bytes(s,fmt)/2;
// Codes and scales are separate in INT8; check code bytes here.
const size_t code_block=fmt==kKvInt8 ? s.page_size*s.n_head_kv*s.head_dim : block;
const void* slots=fmt==kKvQ4?(void*)f.state.k_q4:fmt==kKvInt8?(void*)f.state.k_q:(void*)f.state.k_pool;
std::vector<uint8_t> got(code_block);
for (int64_t page=b0;page<b1;++page) {
cuda_check(cudaMemcpy(got.data(),(const uint8_t*)slots+(page%f.state.n_slots)*code_block,code_block,cudaMemcpyDeviceToHost));
std::vector<uint8_t> expected(code_block);
check(a.k.read(expected.data(), page*code_block, code_block),"read segmented expected draft page");
check(got == expected,"draft ring contains restored pages");
}
}
auto bad=a; bad.head_dim++;
check(!conversation_kv_restore(bad,f.state,f.g,upto,index,err),"reject incompatible geometry");
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");
}
}
}
for (int fmt : {kKvF16,kKvInt8,kKvQ4}) for (int mode : {0,1}) for (int experts : {256,512})
full_session(fmt,mode,experts);
for (int experts : {256,512}) full_session(3,0,experts);
std::printf("conversation_snapshot_test: %d checks passed\n",checks);
}
+305
View File
@@ -0,0 +1,305 @@
#include "strata/core/conversation_snapshot.hpp"
#include "conversation_checked.hpp"
#include <algorithm>
#include <array>
#include <limits>
namespace strata::core {
namespace {
using conversation_detail::add;
using conversation_detail::product;
std::array<int64_t, 18> geometry_key(const ModelGeometry& g) {
return {g.n_embd, g.n_layers, g.qsa_interval, g.ssm_state_size, g.ssm_k_heads,
g.ssm_v_heads, g.ssm_d_conv, g.ssm_conv_channels, g.ssm_value_dim,
g.n_head, g.n_head_kv, g.head_dim, g.idx_q_heads, g.idx_key_dim,
g.hc, g.hc_lr, g.n_expert, g.n_ff};
}
bool fail(std::string& error, const char* message) {
error = std::string("conversation snapshot: ") + message;
return false;
}
bool sync(std::string& error) {
const auto status = cudaDeviceSynchronize();
if (status == cudaSuccess) return true;
error = std::string("conversation snapshot synchronize: ") + cudaGetErrorString(status);
return false;
}
bool copy(void* dst, const void* src, size_t bytes, std::string& error) {
if (!bytes) return true;
if (!dst || !src) return fail(error, "missing running-state buffer");
const auto status = cudaMemcpy(dst, src, bytes, cudaMemcpyDefault);
if (status == cudaSuccess) return true;
error = std::string("conversation snapshot running-state copy: ") + cudaGetErrorString(status);
return false;
}
// The session's carve (#216): `qsa_states` keeps global ordinals and holds the owned ones
// [qsa_ord0, qsa_ord0 + qsa_alloc); `gdn_state` holds `gdn_alloc` rows. A whole-model session owns every layer.
size_t owned_qsa(const SessionState& ss) { return (size_t) std::max<int64_t>(ss.qsa_alloc, 0); }
const QsaState& owned(const SessionState& ss, size_t j) { return ss.qsa_states[(size_t) ss.qsa_ord0 + j]; }
bool image_keys(const std::vector<ConversationImageKey>& images, size_t tokens) {
int64_t previous = -1;
for (const auto& image : images) {
if (image.start <= previous || image.start < 0 || (uint64_t) image.start >= tokens) return false;
previous = image.start;
}
return true;
}
bool checkpoint_targets(const SessionState& ss, const ModelGeometry& g, size_t tokens,
ConversationStateSizes& z, std::string& error) {
if (!conversation_session_sizes(g, ss, z, error)) return false;
if (ss.max_cells < 0 || tokens > (uint64_t) ss.max_cells || (z.gdn && !ss.gdn_state) ||
(owned_qsa(ss) && !ss.qsa_states)) return fail(error, "invalid session running-state targets");
for (size_t j = 0; j < owned_qsa(ss); ++j) {
const auto& st = owned(ss, j);
const auto block = strata::kernels::qsa_real_shapes().idx_block;
size_t pooled_bytes = 0;
if (!st.idx_tail || !st.idx_dead || !st.idx_block_pos || !st.idx_pooled ||
st.max_cells < 0 || tokens > (uint64_t) st.max_cells ||
(tokens && tokens / (uint64_t) block >= (uint64_t) std::max<int64_t>(0, st.idx_pooled_rows)) ||
!product(pooled_bytes, {tokens / (uint64_t) block + 1, (uint64_t) g.idx_key_dim, sizeof(float)}))
return fail(error, "invalid indexer running-state target");
}
return true;
}
bool view_validate(const ConversationView& view, const SessionState& ss,
const ModelGeometry& g, std::string& error) {
ConversationStateSizes z;
if (!checkpoint_targets(ss, g, view.ids.size(), z, error)) return false;
if (view.ids.empty() || !image_keys(view.images, view.ids.size()) ||
std::any_of(view.ids.begin(), view.ids.end(), [](int32_t id) { return id < 0; }))
return fail(error, "invalid live token/image metadata");
for (const auto& c : view.checkpoints) {
if (!c.stage_parts.empty() || c.ids.empty() || c.ids.size() > view.ids.size() ||
!std::equal(c.ids.begin(), c.ids.end(), view.ids.begin()))
return fail(error, "checkpoint is not a live token prefix");
if (!conversation_checkpoint_validate(c, ss, g, error)) return false;
size_t image = 0;
for (const auto& key : view.images) {
if ((uint64_t) key.start >= c.ids.size()) break;
if (image >= c.imgs.size() || !(c.imgs[image++] == key))
return fail(error, "checkpoint image identity differs");
}
if (image != c.imgs.size()) return fail(error, "checkpoint image prefix differs");
}
return true;
}
bool metadata_bytes(const ConversationCheckpoint& c, size_t& total) {
size_t ids = 0, images = 0;
if (!product(ids, {c.ids.size(), sizeof(int32_t)}) ||
!product(images, {c.imgs.size(), sizeof(ConversationImageKey)})) return false;
for (size_t n : {ids, images, c.gdn.size(), c.ple.size(), c.tails.size(), c.dead.size(), c.block_pos.size()})
if (!add(total, n)) return false;
return true;
}
} // namespace
bool conversation_state_sizes(const ModelGeometry& g, ConversationStateSizes& z, std::string& error) {
z = {};
const auto key = geometry_key(g);
for (size_t i = 0; i < key.size(); ++i)
if (key[i] < 0 || (i != 1 && key[i] == 0)) return fail(error, "invalid model geometry");
size_t recurrence = 0, convolution = 0;
if (!product(recurrence, {(uint64_t) g.ssm_state_size, (uint64_t) g.ssm_v_heads, (uint64_t) g.ssm_state_size}) ||
!product(convolution, {(uint64_t) g.ssm_conv_channels, (uint64_t) (g.ssm_d_conv - 1)}) ||
!add(recurrence, convolution) ||
!product(z.gdn, {(uint64_t) g.n_gdn_layers(), recurrence, sizeof(float)}) ||
!product(z.ple, {strata::kernels::NG_HIST, strata::kernels::NG_HC_DIM, sizeof(float)}) ||
!product(z.tail, {(uint64_t) (strata::kernels::qsa_real_shapes().idx_block - 1),
(uint64_t) g.idx_key_dim, sizeof(float)}) ||
!product(z.dead, {(uint64_t) g.idx_key_dim, sizeof(float)}))
return fail(error, "running-state byte count overflow");
z.block_pos = sizeof(int32_t);
size_t total = 0;
if (!product(total, {(uint64_t) g.n_qsa_layers(), z.tail}) ||
!product(total, {(uint64_t) g.n_qsa_layers(), z.dead}) ||
!product(total, {(uint64_t) g.n_qsa_layers(), z.block_pos}))
return fail(error, "indexer byte count overflow");
return true;
}
bool conversation_session_sizes(const ModelGeometry& g, const SessionState& ss, ConversationStateSizes& z,
std::string& error) {
if (!conversation_state_sizes(g, z, error)) return false;
const int64_t n_gdn = g.n_gdn_layers(), n_qsa = g.n_qsa_layers();
if (ss.gdn_alloc < 0 || ss.gdn_alloc > n_gdn || ss.qsa_ord0 < 0 || ss.qsa_alloc < 0 ||
ss.qsa_ord0 > n_qsa || ss.qsa_alloc > n_qsa - ss.qsa_ord0)
return fail(error, "invalid session layer carve");
// z.gdn is n_gdn whole rows; the session holds gdn_alloc of them (all of them for the default full range)
z.gdn = n_gdn > 0 ? z.gdn / (size_t) n_gdn * (size_t) ss.gdn_alloc : 0;
return true;
}
bool conversation_checkpoint_validate(const ConversationCheckpoint& c, const SessionState& ss,
const ModelGeometry& g, std::string& error) {
ConversationStateSizes z;
if (!checkpoint_targets(ss, g, c.ids.size(), z, error)) return false;
const size_t layers = owned_qsa(ss);
if (c.gdn.size() != z.gdn || c.ple.size() != (ss.ple_hist ? z.ple : 0) ||
c.tails.size() != layers * z.tail || c.dead.size() != layers * z.dead ||
c.block_pos.size() != layers * z.block_pos || !image_keys(c.imgs, c.ids.size()))
return fail(error, "invalid checkpoint running-state payload");
return true;
}
bool conversation_checkpoint_save(ConversationCheckpoint& c, const SessionState& ss,
const ModelGeometry& g, std::string& error) {
ConversationStateSizes z;
if (!checkpoint_targets(ss, g, c.ids.size(), z, error)) return false;
const size_t layers = owned_qsa(ss);
c.gdn.resize(z.gdn); c.ple.resize(ss.ple_hist ? z.ple : 0);
c.tails.resize(layers * z.tail); c.dead.resize(layers * z.dead); c.block_pos.resize(layers * z.block_pos);
if (!copy(c.gdn.data(), ss.gdn_state, c.gdn.size(), error) ||
!copy(c.ple.data(), ss.ple_hist, c.ple.size(), error)) return false;
for (size_t j = 0; j < layers; ++j) {
const auto& st = owned(ss, j);
if (!copy(c.tails.data() + j * z.tail, st.idx_tail, z.tail, error) ||
!copy(c.dead.data() + j * z.dead, st.idx_dead, z.dead, error) ||
!copy(c.block_pos.data() + j * z.block_pos, st.idx_block_pos, z.block_pos, error)) return false;
}
return true;
}
bool conversation_checkpoint_restore(const ConversationCheckpoint& c, SessionState& ss,
const ModelGeometry& g, std::string& error) {
if (!conversation_checkpoint_validate(c, ss, g, error)) return false;
ConversationStateSizes z;
if (!conversation_session_sizes(g, ss, z, error)) return false;
if (!copy(ss.gdn_state, c.gdn.data(), c.gdn.size(), error) ||
!copy(ss.ple_hist, c.ple.data(), c.ple.size(), error)) return false;
for (size_t j = 0; j < owned_qsa(ss); ++j) {
const auto& st = owned(ss, j);
if (!copy(st.idx_tail, c.tails.data() + j * z.tail, z.tail, error) ||
!copy(st.idx_dead, c.dead.data() + j * z.dead, z.dead, error) ||
!copy(st.idx_block_pos, c.block_pos.data() + j * z.block_pos, z.block_pos, error)) return false;
if (!c.ids.empty()) {
const size_t row = c.ids.size() / strata::kernels::qsa_real_shapes().idx_block;
if (!copy(st.idx_pooled + row * g.idx_key_dim, c.dead.data() + j * z.dead, z.dead, error)) return false;
}
}
const size_t tokens = c.ids.size();
ss.ple_prev[0] = tokens >= 2 ? c.ids[tokens - 2] : -1;
ss.ple_prev[1] = tokens >= 1 ? c.ids[tokens - 1] : -1;
return sync(error);
}
bool conversation_snapshot_bytes(const ConversationView& view, const SessionState& ss,
const ModelGeometry& g, const QsaState& draft, size_t& bytes, std::string& error) {
bytes = 0;
if (!view_validate(view, ss, g, error)) return false;
ConversationStateSizes z;
if (!conversation_session_sizes(g, ss, z, error)) return false;
size_t ids = 0, images = 0, checkpoints = 0, layers = 0, tails = 0, dead = 0, positions = 0;
const auto qsa = (uint64_t) owned_qsa(ss);
if (!product(ids, {view.ids.size(), sizeof(int32_t)}) ||
!product(images, {view.images.size(), sizeof(ConversationImageKey)}) ||
!product(checkpoints, {view.checkpoints.size(), sizeof(ConversationCheckpoint)}) ||
!product(layers, {qsa + 1, sizeof(ConversationKv)}) ||
!product(tails, {qsa, z.tail}) || !product(dead, {qsa, z.dead}) ||
!product(positions, {qsa, z.block_pos})) return fail(error, "snapshot metadata byte count overflow");
for (size_t n : {ids, images, checkpoints, layers, tails, dead, positions, z.gdn, ss.ple_hist ? z.ple : 0})
if (!add(bytes, n)) return fail(error, "snapshot byte count overflow");
for (const auto& c : view.checkpoints)
if (!metadata_bytes(c, bytes)) return fail(error, "checkpoint byte count overflow");
const int64_t upto = (int64_t) view.ids.size(); // view_validate bounds this by signed max_cells
for (uint64_t i = 0; i <= qsa; ++i) {
const auto& st = i == qsa ? draft : owned(ss, (size_t) i);
const size_t n = conversation_kv_bytes(st, g, upto, i != qsa);
if (!n || !add(bytes, n)) return fail(error, "invalid or overflowing K/V byte estimate");
}
return true;
}
bool conversation_snapshot_capture_bytes(const ConversationKvReuse& reuse, const ConversationView& view,
const SessionState& ss, const ModelGeometry& g,
const QsaState& draft, size_t& bytes, std::string& error) {
if (!conversation_snapshot_bytes(view, ss, g, draft, bytes, error)) return false;
if (reuse.kv.empty()) return true;
const size_t layers = owned_qsa(ss) + 1;
if (reuse.kv.size() != layers || reuse.unchanged_tokens < 0 ||
reuse.unchanged_tokens > reuse.captured_tokens || reuse.unchanged_tokens > int64_t(view.ids.size()))
return fail(error, "invalid retained K/V prefix");
for (size_t i = 0; i < layers; ++i) {
const bool index = i + 1 != layers;
const auto& st = index ? owned(ss, i) : draft;
if (!conversation_kv_validate(reuse.kv[i], st, g, reuse.captured_tokens, index, error)) return false;
const size_t fresh = conversation_kv_bytes(st, g, int64_t(view.ids.size()), index);
size_t retained = 0;
if (!conversation_kv_capture_bytes(reuse.kv[i], st, g, int64_t(view.ids.size()), index, retained, error)) return false;
bytes -= fresh;
if (!add(bytes, retained)) return fail(error, "retained K/V allocation overflow");
}
size_t directory = 0;
if (!product(directory, {reuse.kv.capacity() - layers, sizeof(ConversationKv)}) || !add(bytes, directory))
return fail(error, "retained K/V directory overflow");
return true;
}
bool conversation_snapshot_save(SavedConversation& image, const ConversationView& view,
const SessionState& ss, const ModelGeometry& g,
const QsaState& draft, std::string& error,
ConversationKvReuse reuse, size_t* reused_bytes) {
size_t estimate = 0;
if (!conversation_snapshot_capture_bytes(reuse, view, ss, g, draft, estimate, error) || !sync(error)) return false;
// Build into a new object so a failure cannot publish a partial snapshot.
SavedConversation captured;
captured.geometry = geometry_key(g);
captured.layer_lo = ss.layer_lo; captured.layer_hi = ss.layer_hi;
captured.live.ids = view.ids; captured.live.imgs = view.images;
captured.cvec = view.cvec; captured.checkpoints = view.checkpoints;
const int64_t unchanged = reuse.kv.empty() ? 0 : reuse.unchanged_tokens;
captured.kv = std::move(reuse.kv);
const size_t layers = owned_qsa(ss);
captured.kv.resize(layers + 1);
if (!conversation_checkpoint_save(captured.live, ss, g, error)) return false;
const int64_t upto = (int64_t) view.ids.size();
for (size_t j = 0; j < layers; ++j)
if (!conversation_kv_save(captured.kv[j], owned(ss, j), g, upto, true, error,
unchanged, reused_bytes)) return false;
// The draft's final cell may not have been computed when the output cap was
// reached. Refresh that page even when the main prefix continued unchanged.
if (!conversation_kv_save(captured.kv.back(), draft, g, upto, false, error,
std::max<int64_t>(0, unchanged - 1), reused_bytes)) return false;
image = std::move(captured);
return true;
}
bool conversation_snapshot_validate(const SavedConversation& image, const SessionState& ss,
const ModelGeometry& g, const QsaState& draft, std::string& error) {
if (!image.live.stage_parts.empty()) return fail(error, "layer-split parking is not supported");
if (image.geometry != geometry_key(g)) return fail(error, "incompatible runtime geometry");
// an image holds exactly one carve's running state and K/V: same layer range or nothing
if (image.layer_lo != ss.layer_lo || image.layer_hi != ss.layer_hi)
return fail(error, "snapshot from another session layer range");
const ConversationView view{image.live.ids, image.live.imgs, image.checkpoints, image.cvec};
if (!view_validate(view, ss, g, error) || !conversation_checkpoint_validate(image.live, ss, g, error)) return false;
const size_t layers = owned_qsa(ss);
if (image.kv.size() != layers + 1) return fail(error, "invalid K/V layer count");
const int64_t upto = (int64_t) image.live.ids.size();
for (size_t j = 0; j < layers; ++j)
if (!conversation_kv_validate(image.kv[j], owned(ss, j), g, upto, true, error)) return false;
return conversation_kv_validate(image.kv.back(), draft, g, upto, false, error);
}
ConversationRestore conversation_snapshot_restore(const SavedConversation& image, SessionState& ss,
const ModelGeometry& g, const QsaState& draft, std::string& error) {
if (!conversation_snapshot_validate(image, ss, g, draft, error)) return ConversationRestore::invalid;
if (!sync(error)) return ConversationRestore::transfer_failed;
const int64_t upto = (int64_t) image.live.ids.size();
for (size_t j = 0; j < owned_qsa(ss); ++j)
if (!conversation_kv_restore(image.kv[j], owned(ss, j), g, upto, true, error))
return ConversationRestore::transfer_failed;
if (!conversation_kv_restore(image.kv.back(), draft, g, upto, false, error) ||
!conversation_checkpoint_restore(image.live, ss, g, error)) return ConversationRestore::transfer_failed;
return ConversationRestore::restored;
}
} // namespace strata::core
+239
View File
@@ -0,0 +1,239 @@
// Host-only validation fixtures. CUDA is linked but must never be initialized:
// invalid restores must return before the first CUDA call or destination write.
#include "strata/core/conversation_snapshot.hpp"
#include "strata/kernels/kv_q4.hpp"
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <limits>
#include <cstring>
#if defined(CONVERSATION_TEST_TRANSFERS)
// GNU/ELF link wrapping exercises the actual restore control flow without
// initializing CUDA or deliberately poisoning a real device context. This is
// host-backend fault injection, not a claim of hardware-failure recovery.
namespace {
int copy_calls = 0, sync_calls = 0, fail_copy = 0, fail_sync = 0;
size_t copied_bytes = 0;
}
extern "C" cudaError_t __wrap_cudaMemcpy(void* dst, const void* src, size_t n, cudaMemcpyKind) {
if (++copy_calls == fail_copy) return cudaErrorInvalidValue;
copied_bytes += n;
std::memcpy(dst, src, n);
return cudaSuccess;
}
extern "C" cudaError_t __wrap_cudaDeviceSynchronize() {
return ++sync_calls == fail_sync ? cudaErrorUnknown : cudaSuccess;
}
extern "C" cudaError_t __wrap_cudaGetLastError() { return cudaSuccess; }
#endif
using namespace strata::core;
namespace {
int checks = 0;
void check(bool ok, const char* label) {
++checks;
if (!ok) { std::fprintf(stderr, "FAIL: %s\n", label); std::exit(1); }
}
struct Pools {
QsaState st;
std::array<std::vector<uint8_t>, 8> data;
Pools(const ModelGeometry& g, int format) {
st.max_cells = 96; st.n_pages = st.n_slots = 24; st.idx_pooled_rows = 26;
st.kv_int8 = format == 1; st.kv_q4 = format == 2;
const size_t per = format == 2 ? strata::kernels::kv_q4_bytes_per_head((int) g.head_dim)
: g.head_dim * (format == 1 ? 1 : 2);
data[0].resize(96 * g.n_head_kv * per, 0xa5); data[1] = data[0];
data[2].resize(format == 1 ? 96 * g.n_head_kv * (g.head_dim / 64) * 2 : 0, 0xa5); data[3] = data[2];
data[4].resize(26 * g.idx_key_dim * 4, 0xa5);
data[5].resize(3 * g.idx_key_dim * 4, 0xa5);
data[6].resize(g.idx_key_dim * 4, 0xa5); data[7].resize(4, 0xa5);
if (format == 2) { st.k_q4 = data[0].data(); st.v_q4 = data[1].data(); }
else if (format == 1) {
st.k_q = (int8_t*) data[0].data(); st.v_q = (int8_t*) data[1].data();
st.k_scale = (uint16_t*) data[2].data(); st.v_scale = (uint16_t*) data[3].data();
} else { st.k_pool = (uint16_t*) data[0].data(); st.v_pool = (uint16_t*) data[1].data(); }
st.idx_pooled = (float*) data[4].data(); st.idx_tail = (float*) data[5].data();
st.idx_dead = (float*) data[6].data(); st.idx_block_pos = (int32_t*) data[7].data();
}
ConversationKv image(const ModelGeometry& g, bool index) const {
ConversationKv k;
k.format = qsa_kv_format(st); k.cells = 12; k.page_size = 4;
k.heads = g.n_head_kv; k.head_dim = g.head_dim; k.idx_dim = g.idx_key_dim;
k.pooled_rows = index ? 3 : 0;
k.k.resize(data[0].size() / 8, 13); k.v = k.k;
k.k_scale.resize(data[2].size() / 8, 13); k.v_scale = k.k_scale;
k.pooled.resize(k.pooled_rows * g.idx_key_dim * 4, 13);
return k;
}
};
void fixture(int format, int experts, bool zero_qsa, bool ple) {
ModelGeometry g;
g.n_layers = zero_qsa ? 3 : 8; g.n_expert = experts;
g.ssm_state_size = 2; g.ssm_v_heads = 2; g.ssm_conv_channels = 8;
g.n_head_kv = 1; g.head_dim = 64; g.idx_key_dim = 8;
std::string error;
ConversationStateSizes z;
check(conversation_state_sizes(g, z, error), "checked canonical/pruned/zero-QSA sizing");
Pools first(g, format), last(g, format), draft(g, format);
std::array<QsaState, 2> layers{first.st, last.st};
std::vector<uint8_t> gdn(z.gdn, 0xa5), history(ple ? z.ple : 0, 0xa5);
SessionState ss;
ss.max_cells = 96; ss.gdn_state = (float*) gdn.data();
// the whole-model carve, as session_init leaves a one-GPU session
ss.layer_hi = g.n_layers; ss.gdn_alloc = g.n_gdn_layers(); ss.qsa_alloc = g.n_qsa_layers();
ss.ple_hist = ple ? (float*) history.data() : nullptr;
ss.qsa_states = zero_qsa ? nullptr : layers.data();
SavedConversation image;
image.geometry = {g.n_embd,g.n_layers,g.qsa_interval,g.ssm_state_size,g.ssm_k_heads,g.ssm_v_heads,
g.ssm_d_conv,g.ssm_conv_channels,g.ssm_value_dim,g.n_head,g.n_head_kv,g.head_dim,
g.idx_q_heads,g.idx_key_dim,g.hc,g.hc_lr,g.n_expert,g.n_ff};
image.layer_hi = g.n_layers;
auto checkpoint = [&](size_t tokens) {
ConversationCheckpoint c;
c.ids.resize(tokens);
for (size_t i = 0; i < tokens; ++i) c.ids[i] = (int32_t) i + 1;
c.imgs = {{1, 44}};
c.gdn.resize(z.gdn, 13); c.ple.resize(ple ? z.ple : 0, 13);
c.tails.resize(g.n_qsa_layers() * z.tail, 13); c.dead.resize(g.n_qsa_layers() * z.dead, 13);
c.block_pos.resize(g.n_qsa_layers() * z.block_pos, 13);
return c;
};
image.live = checkpoint(9); image.checkpoints.push_back(checkpoint(5));
image.kv.reserve((size_t) g.n_qsa_layers() + 1);
if (!zero_qsa) { image.kv.push_back(first.image(g, true)); image.kv.push_back(last.image(g, true)); }
image.kv.push_back(draft.image(g, false));
check(conversation_snapshot_validate(image, ss, g, draft.st, error), "complete image validates without CUDA");
size_t estimate = 0;
check(conversation_snapshot_bytes({image.live.ids,image.live.imgs,image.checkpoints,true},ss,g,draft.st,estimate,error),
"capture estimate works without CUDA");
check(estimate == image.bytes(), "estimate covers checkpoint/indexer/spare payloads");
auto unchanged = [&] {
auto pristine = [](const auto& bytes) { return std::all_of(bytes.begin(),bytes.end(),[](uint8_t b){return b==0xa5;}); };
if (!pristine(gdn) || !pristine(history) || ss.ple_prev[0] != -1 || ss.ple_prev[1] != -1) return false;
for (const auto* p : {&first,&last,&draft}) for (const auto& bytes : p->data) if (!pristine(bytes)) return false;
return true;
};
auto reject = [&](const std::function<void(SavedConversation&)>& mutate, const char* label) {
auto bad = image; mutate(bad);
check(conversation_snapshot_restore(bad,ss,g,draft.st,error) == ConversationRestore::invalid,label);
check(unchanged(), "invalid restore did not touch any layer or running-state buffer");
};
reject([](auto& s){s.kv.back().k.pop_back();}, "late draft corruption rejected before first layer write");
reject([](auto& s){s.geometry[16] = 128;}, "different expert geometry rejected");
reject([](auto& s){s.live.stage_parts.emplace_back();}, "layer-split live state rejected before writes");
reject([](auto& s){s.checkpoints[0].stage_parts.emplace_back();}, "layer-split checkpoint rejected before writes");
reject([](auto& s){s.kv.pop_back();}, "missing KV layer rejected");
reject([](auto& s){s.live.gdn.pop_back();}, "bad live recurrence rejected");
reject([](auto& s){s.checkpoints.back().gdn.pop_back();}, "bad retained checkpoint rejected");
reject([](auto& s){s.checkpoints.back().ids[0] = 99;}, "foreign checkpoint prefix rejected");
reject([](auto& s){s.checkpoints.back().imgs[0].hash++;}, "foreign checkpoint image rejected");
reject([](auto& s){s.live.imgs[0].start = -1;}, "negative image position rejected");
reject([](auto& s){s.live.ids[0] = -1;}, "negative live token rejected");
reject([](auto& s){s.live.ple.push_back(0);}, "PLE size/enabled mismatch rejected");
reject([](auto& s){s.live.tails.push_back(0);}, "tail size/zero-QSA mismatch rejected");
if (!zero_qsa) {
reject([](auto& s){s.kv[1].pooled.pop_back();}, "late indexer spare row corruption rejected");
reject([](auto& s){s.live.dead.pop_back();}, "missing indexer spare key rejected");
reject([](auto& s){s.live.block_pos.pop_back();}, "missing indexer metadata rejected");
ss.qsa_states = nullptr;
check(!conversation_snapshot_validate(image,ss,g,draft.st,error), "missing QSA targets rejected");
ss.qsa_states = layers.data();
reject([](auto& s){s.layer_lo = 4;}, "snapshot from another layer range rejected");
// #216's carve: a split stage owning layers [4, 8) holds GDN rows 3..5 and QSA ordinal 1 only
SessionState stage = ss;
stage.layer_lo = 4; stage.gdn_alloc = 3; stage.qsa_ord0 = 1; stage.qsa_alloc = 1;
ConversationStateSizes zs;
check(conversation_session_sizes(g,stage,zs,error) && zs.gdn == z.gdn / 6 * 3 && zs.tail == z.tail,
"carved session sizes cover the owned rows only");
check(!conversation_checkpoint_validate(image.live,stage,g,error), "whole-model checkpoint rejected by a carve");
ConversationCheckpoint part = image.live;
part.gdn.resize(zs.gdn); part.tails.resize(zs.tail); part.dead.resize(zs.dead);
part.block_pos.resize(zs.block_pos);
check(conversation_checkpoint_validate(part,stage,g,error), "carve-sized checkpoint validates");
check(!conversation_snapshot_validate(image,stage,g,draft.st,error), "whole-model snapshot rejected by a carve");
stage.qsa_alloc = 2;
check(!conversation_session_sizes(g,stage,zs,error), "carve past the last QSA ordinal rejected");
stage.qsa_alloc = 1; stage.gdn_alloc = 7;
check(!conversation_session_sizes(g,stage,zs,error), "carve past the GDN rows rejected");
}
auto bad_geometry = g; bad_geometry.ssm_state_size = std::numeric_limits<int64_t>::max();
check(!conversation_state_sizes(bad_geometry,z,error), "running-state arithmetic overflow rejected");
bad_geometry = g; bad_geometry.qsa_interval = 0;
check(!conversation_state_sizes(bad_geometry,z,error), "zero layer interval rejected before division");
bad_geometry = g; bad_geometry.n_head_kv = std::numeric_limits<int64_t>::max();
check(conversation_kv_bytes(draft.st,bad_geometry,9,false)==0, "KV byte overflow rejected");
#if defined(CONVERSATION_TEST_TRANSFERS)
auto reset = [&] {
for (auto* p : {&first,&last,&draft}) for (auto& bytes : p->data) std::fill(bytes.begin(),bytes.end(),0xa5);
std::fill(gdn.begin(),gdn.end(),0xa5); std::fill(history.begin(),history.end(),0xa5);
ss.ple_prev[0] = ss.ple_prev[1] = -1;
copy_calls = sync_calls = fail_copy = fail_sync = 0;
};
reset(); fail_sync = 1;
check(conversation_snapshot_restore(image,ss,g,draft.st,error)==ConversationRestore::transfer_failed,
"pre-transfer CUDA synchronization failure is fatal");
check(copy_calls==0 && unchanged(), "pre-sync failure did not mutate state");
reset(); fail_copy = 1;
check(conversation_snapshot_restore(image,ss,g,draft.st,error)==ConversationRestore::transfer_failed,
"first CUDA transfer failure is fatal");
check(unchanged(), "first-copy failure did not mutate state");
reset(); fail_copy = 2;
check(conversation_snapshot_restore(image,ss,g,draft.st,error)==ConversationRestore::transfer_failed,
"partial CUDA transfer failure is fatal, not an invalid-image fallback");
check(!unchanged(), "fault fixture genuinely produced partial state");
reset(); fail_sync = 2;
check(conversation_snapshot_restore(image,ss,g,draft.st,error)==ConversationRestore::transfer_failed,
"post-transfer CUDA synchronization failure is fatal");
check(copy_calls>0 && !unchanged(), "post-sync failure occurred after state writes");
reset();
check(conversation_snapshot_restore(image,ss,g,draft.st,error)==ConversationRestore::restored,
"injected host transfer backend completes a valid restore");
check(gdn==image.live.gdn && history==image.live.ple && ss.ple_prev[0]==8 && ss.ple_prev[1]==9,
"successful restore publishes correct running state and PLE window");
uint64_t fingerprint = 0;
check(conversation_kv_verify(image.kv.back(),draft.st,g,9,false,fingerprint,error), "read-back verifies complete draft payload");
draft.data[0][0] ^= 1;
check(!conversation_kv_verify(image.kv.back(),draft.st,g,9,false,fingerprint,error), "read-back detects corrupted draft byte");
draft.data[0][0] ^= 1;
fail_copy = copy_calls + 1;
check(!conversation_kv_verify(image.kv.back(),draft.st,g,9,false,fingerprint,error), "read-back transfer failure is not a successful fingerprint");
reset(); copied_bytes = 0;
const ConversationView view{image.live.ids,image.live.imgs,image.checkpoints,true};
SavedConversation full,incremental;
check(conversation_snapshot_save(full,view,ss,g,draft.st,error), "full capture through host transfer backend");
const size_t full_copies = copied_bytes;
ConversationKvReuse reuse{full.kv,9,9};
size_t peak = 0, reused = 0;
check(conversation_snapshot_capture_bytes(reuse,view,ss,g,draft.st,peak,error), "incremental capture admission without transfers");
copied_bytes = 0;
const bool saved_incrementally = conversation_snapshot_save(incremental,view,ss,g,draft.st,error,std::move(reuse),&reused);
if (!saved_incrementally) std::fprintf(stderr,"capture error: %s\n",error.c_str());
check(saved_incrementally, "incremental capture through host backend");
check(reused > 0 && copied_bytes + reused == full_copies, "reused byte count measures transfers actually omitted");
check(incremental.bytes() <= peak && incremental.live.gdn == full.live.gdn, "running state refreshed within admitted allocation");
for (size_t i=0;i<full.kv.size();++i)
check(incremental.kv[i].k==full.kv[i].k && incremental.kv[i].v==full.kv[i].v &&
incremental.kv[i].k_scale==full.kv[i].k_scale && incremental.kv[i].v_scale==full.kv[i].v_scale &&
incremental.kv[i].pooled==full.kv[i].pooled, "incremental and full capture agree across every payload");
reuse = {full.kv,9,9};
reuse.kv.back().k.pop_back();
copy_calls = sync_calls = 0;
check(!conversation_snapshot_capture_bytes(reuse,view,ss,g,draft.st,peak,error), "malformed retained draft rejected before admission");
check(!copy_calls && !sync_calls, "invalid retained storage performs no CUDA calls");
reuse = {full.kv,9,9};
fail_copy = copy_calls + 1;
SavedConversation unpublished;
check(!conversation_snapshot_save(unpublished,view,ss,g,draft.st,error,std::move(reuse)), "incremental capture copy failure is reported");
check(unpublished.kv.empty() && unpublished.live.ids.empty(), "failed incremental capture cannot publish a partial image");
#endif
}
}
int main() {
for (int format : {0,1,2}) for (int experts : {256,512})
for (bool zero_qsa : {false,true}) for (bool ple : {false,true}) fixture(format,experts,zero_qsa,ple);
std::printf("conversation_validation_test: %d host-only checks passed\n", checks);
}
+115
View File
@@ -0,0 +1,115 @@
// src/core/coupled_draft_test.cpp - coupled draft sampling's host-side arithmetic (include/strata/core/coupled_draft.hpp).
//
// No GPU. A simulated decode - windows of random size, random accepted counts, the serve loop's first window of one
// token and the CLI's `draft_first` - checks that
// 1. every draft the MTP chain makes is drawn with the Philox counter of the verify row that checks it: the verify
// window at pos0 draws row t with pos0 + t (verify.cpp run()), and row t checks the draft in row t + 1;
// 2. the MTP cell of each draft predicts exactly the position that draft occupies in the next window;
// 3. the penalty window each draft sees in the device ring (the staged base, then the chain's earlier drafts) is
// token for token the history row `penalty_rows` gives the verify row that checks it;
// 4. the penalty window length matches the target's (serve caps the history at 4,096).
#include "strata/core/coupled_draft.hpp"
#include <cstdio>
#include <random>
#include <vector>
using namespace strata::core;
namespace {
int g_fail = 0;
int64_t g_checks = 0;
void check(bool ok, const char* what, long long a = 0, long long b = 0) {
++g_checks;
if (!ok) {
if (++g_fail <= 20) std::printf(" FAIL %s (%lld vs %lld)\n", what, a, b);
}
}
// verify.cpp run(): sp.counter = pos0, and the kernel draws row t with sp.counter + t
uint64_t verify_row_counter(int64_t pos0, int t) { return (uint64_t) pos0 + (uint64_t) t; }
// The counters and positions of a chain of `n` drafts made after a window at `p` with `a` accepted (draft()), or for
// draft_first (cell0 = p - 1, a = 0), checked against the next window at `p_next`.
void check_chain(int64_t p, int a, int n, int64_t p_next) {
for (int j = 0; j < n; ++j) {
const int64_t cell = coupled_draft_cell(p, a, j);
// mtp.hpp: cell c predicts the token at c + 2; the next window's row j + 1 holds position p_next + j + 1
check(cell + 2 == p_next + j + 1, "draft position = its row in the next window", cell + 2, p_next + j + 1);
// row j + 1 is kept iff it equals row j's pick, drawn at p_next + j
check(coupled_draft_counter(cell) == verify_row_counter(p_next, j), "draft counter = verifying row's counter",
(long long) coupled_draft_counter(cell), (long long) verify_row_counter(p_next, j));
}
}
// The device ring for one chain: base staged at [cap - h, cap) by the host, draft i at cap + i; draft j's window is
// [coupled_hist_start(cap, j, h), + h). Compared with penalty_rows over (consumed, [next, drafts...]).
void check_history(std::mt19937& rng, int cap, int h, int n_consumed, int n_drafts) {
std::uniform_int_distribution<int> tok(0, 999);
std::vector<int32_t> consumed((size_t) n_consumed), drafts((size_t) n_drafts);
for (auto& t : consumed) t = tok(rng);
for (auto& t : drafts) t = tok(rng);
const int32_t next = tok(rng);
std::vector<int32_t> mapped((size_t) cap, 777777), ring((size_t) (cap + n_drafts), 888888);
coupled_hist_base(consumed.data(), (int64_t) consumed.size(), next, h, mapped.data() + (cap - h)); // set_draft_history
for (int i = cap - h; i < cap; ++i) ring[(size_t) i] = mapped[(size_t) i]; // coupled_stage_kernel
// the next window: [next, drafts...], T = n_drafts + 1 rows; row j checks draft j (row j + 1)
std::vector<int32_t> window;
window.push_back(next);
window.insert(window.end(), drafts.begin(), drafts.end());
const int T = (int) window.size();
std::vector<int32_t> rows((size_t) T * (size_t) h);
strata::kernels::penalty_rows(consumed.data(), (int64_t) consumed.size(), window.data(), T, h, rows.data());
for (int j = 0; j < n_drafts; ++j) {
const int s = coupled_hist_start(cap, j, h);
check(s >= 0 && s + h <= cap + n_drafts, "the window lies inside the ring", s, cap + n_drafts);
for (int i = 0; i < h; ++i)
check(ring[(size_t) (s + i)] == rows[(size_t) j * (size_t) h + (size_t) i], "draft history = verify row history",
ring[(size_t) (s + i)], rows[(size_t) j * (size_t) h + (size_t) i]);
ring[(size_t) (cap + j)] = drafts[(size_t) j]; // coupled_merge_kernel appends the draft after sampling it
}
}
} // namespace
int main() {
std::printf("coupled_draft_test\n");
std::mt19937 rng(20260930);
// ---- 1 + 2: a serve-style decode (the first window is the last prompt token alone, then MTP windows)
for (int run = 0; run < 200; ++run) {
std::uniform_int_distribution<int> tdist(1, 8);
int64_t p = 100 + run * 37; // n - 1
int T = 1;
for (int round = 0; round < 300; ++round) {
std::uniform_int_distribution<int> adist(0, T - 1);
const int a = adist(rng);
const int64_t p_next = p + a + 1;
const int n = 7; // the chain makes up to max_t - 1 drafts, whatever T the next window takes
check_chain(p, a, n, p_next);
p = p_next;
T = tdist(rng); // --spec-min-p / the draft policy choose the next window's size
}
}
// the CLI's draft_first: the first window at p, drafts from cell p - 1 with a = 0
for (int64_t p = 1; p < 5000; p += 13) check_chain(p - 1, 0, 7, p);
// ---- 3: the penalty ring against penalty_rows
for (int cap : {8, 64, kCoupledHistCap}) {
for (int h : {1, 3, 8}) {
if (h > cap) continue;
for (int n_consumed : {0, 1, 2, 5, 7, 20, 100})
for (int n_drafts : {1, 3, 7}) check_history(rng, cap, h, n_consumed, n_drafts);
}
check_history(rng, cap, cap, 3, 7); // the window is the whole ring base
check_history(rng, cap, cap, 5000, 7);
}
// ---- 4: the window length the target uses: min(penalty_last_n, history_len) with history_len =
// min(penalty_last_n, 4096) in serve (0 = off)
for (int pln : {-1, 0, 1, 64, 4095, 4096, 4097, 100000}) {
const int hist_n = std::min(std::max(pln, 0), 4096);
const int target = hist_n > 0 ? std::min(pln, hist_n) : 0;
check(coupled_hist_len(pln, kCoupledHistCap) == target, "penalty window length = the target's",
coupled_hist_len(pln, kCoupledHistCap), target);
}
std::printf(" %lld checks, %d failed\n", (long long) g_checks, g_fail);
std::printf("%s\n", g_fail == 0 ? "PASS" : "FAIL");
return g_fail == 0 ? 0 : 1;
}
+83 -6
View File
@@ -24,13 +24,83 @@ __global__ void poison_kernel(float* p, uint64_t n_floats) {
if (i < n_floats) p[i] = __int_as_float(0x7fc00000);
}
#if defined(STRATA_USE_HIP)
#if !defined(STRATA_HIP_ARCHS)
#error "STRATA_HIP_ARCHS (the compiled HIP architectures) is set by cmake/hip_backend.cmake"
#endif
// "gfx1201:sramecc-:xnack-" -> "gfx1201"
std::string base_arch(const char* gcn_arch_name) {
std::string arch(gcn_arch_name);
const size_t colon = arch.find(':');
if (colon != std::string::npos) arch.resize(colon);
return arch;
}
bool compiled_for(const std::string& arch) {
const std::string list = STRATA_HIP_ARCHS;
size_t a = 0;
while (a <= list.size()) {
size_t b = list.find(',', a);
if (b == std::string::npos) b = list.size();
if (!arch.empty() && list.compare(a, b - a, arch) == 0 && b - a == arch.size()) return true;
a = b + 1;
}
return false;
}
std::string arch_problem(const cudaDeviceProp& p, int ordinal) {
const std::string arch = base_arch(p.gcnArchName);
const std::string card = "GPU " + std::to_string(ordinal) + " (" + p.name + ", " + arch + ")";
if (!compiled_for(arch)) {
return card + " is not an architecture this Strata engine was compiled for (" + STRATA_HIP_ARCHS +
"); compile it for this card (./setup.sh --backend hip, or -DCMAKE_HIP_ARCHITECTURES=" + arch +
", docs/AMD_HIP.md) or choose another GPU with HIP_VISIBLE_DEVICES";
}
if (p.warpSize != 32) {
return card + " runs wave" + std::to_string(p.warpSize) + "; Strata's HIP kernels need wave32";
}
return "";
}
#endif
} // namespace
const char* compiled_gpu_archs() {
#if defined(STRATA_USE_HIP)
return STRATA_HIP_ARCHS;
#else
return "";
#endif
}
std::string gpu_arch_problem(int ordinal) {
#if defined(STRATA_USE_HIP)
int count = 0;
if (cudaGetDeviceCount(&count) != cudaSuccess || ordinal < 0 || ordinal >= count) {
cudaGetLastError();
return "";
}
cudaDeviceProp p{};
if (cudaGetDeviceProperties(&p, ordinal) != cudaSuccess) {
cudaGetLastError();
return "";
}
return arch_problem(p, ordinal);
#else
(void) ordinal;
return "";
#endif
}
DeviceInfo device_info(int ordinal) {
int count = 0;
check(cudaGetDeviceCount(&count), "cudaGetDeviceCount");
if (count == 0) {
throw CudaError("no CUDA device is present; Strata targets sm_120 (RTX 5000 series)", -1);
#if defined(STRATA_USE_HIP)
throw CudaError(std::string("no HIP device is present; this engine was compiled for ") + STRATA_HIP_ARCHS, -1);
#else
throw CudaError("no CUDA device is present; Strata needs an NVIDIA GPU (RTX 20 series or newer)", -1);
#endif
}
if (ordinal < 0 || ordinal >= count) {
throw CudaError("device ordinal " + std::to_string(ordinal) + " is out of range (have " +
@@ -56,15 +126,22 @@ DeviceInfo device_info(int ordinal) {
check(cudaDriverGetVersion(&d.driver_version), "cudaDriverGetVersion");
check(cudaRuntimeGetVersion(&d.runtime_version), "cudaRuntimeGetVersion");
// The engine is written against sm_120. Compiling for it is enforced by CMake; RUNNING on something else
// is caught here, because a binary can be carried to a machine with an older card and would otherwise
// silently take whatever path the driver chose.
if (d.cc_major != 12) {
// The engine supports compute capability 7.5 and newer (Turing: the QSA scorer's tf32 mma has a portable
// fp32-FMA fallback below sm_80, the tensor-core prompt kernels refuse and fall back). Compiling for a
// supported arch is enforced by CMake; RUNNING on an older card is caught here, because a binary can be carried
// to a machine with an older card and would otherwise silently take whatever path the driver chose. The HIP
// backend checks the card against the architectures the binary was compiled for (and wave32).
#if defined(STRATA_USE_HIP)
d.arch = base_arch(p.gcnArchName);
if (const std::string why = arch_problem(p, ordinal); !why.empty()) throw CudaError(why, -1);
#else
if (d.cc_major * 10 + d.cc_minor < 75) {
throw CudaError("device " + d.name + " reports compute capability " + std::to_string(d.cc_major) +
"." + std::to_string(d.cc_minor) +
"; Strata targets sm_120 (RTX 5000 series / Blackwell) only",
"; Strata needs compute capability 7.5 or newer (RTX 20 / 30 / 40 / 50 series)",
-1);
}
#endif
return d;
}
+5
View File
@@ -33,7 +33,12 @@ int main(int argc, char** argv) {
try {
const strata::core::DeviceInfo d = strata::core::device_info(0);
std::printf("device %d: %s\n", d.ordinal, d.name.c_str());
#if defined(STRATA_USE_HIP)
std::printf(" HIP arch %s wave32 (compiled for %s)\n", d.arch.c_str(),
strata::core::compiled_gpu_archs());
#else
std::printf(" compute capability %d.%d (sm_%d%d)\n", d.cc_major, d.cc_minor, d.cc_major, d.cc_minor);
#endif
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);
+44
View File
@@ -69,6 +69,22 @@ bool read_expert_profile(const std::string& path, int64_t n_layers, int64_t n_ex
ExpertCache::~ExpertCache() { close(); }
#if defined(STRATA_USE_HIP)
bool ExpertCache::ensure_blocking_staging(std::size_t bytes, std::string& err) {
if (bytes <= blocking_staging_bytes_) return true;
void* next = nullptr;
const cudaError_t status = cudaHostAlloc(&next, bytes, cudaHostAllocDefault);
if (status != cudaSuccess) {
err = std::string("ExpertCache: HIP blocking staging allocation: ") + cudaGetErrorString(status);
return false;
}
if (blocking_staging_) (void) cudaFreeHost(blocking_staging_);
blocking_staging_ = static_cast<uint8_t*>(next);
blocking_staging_bytes_ = bytes;
return true;
}
#endif
bool ExpertCache::open(int64_t n_slots, int64_t n_layers, int64_t n_expert, int64_t blob_bytes,
std::string& err) {
close();
@@ -125,6 +141,12 @@ bool ExpertCache::open(int64_t n_slots, int64_t n_layers, int64_t n_expert, int6
n_layers_ = n_layers;
n_expert_ = n_expert;
blob_ = blob_bytes;
#if defined(STRATA_USE_HIP)
if (!ensure_blocking_staging((std::size_t) blob_, err)) {
close();
return false;
}
#endif
next_free_ = 0;
fills_ = 0;
admitted_ = 0;
@@ -154,11 +176,22 @@ bool ExpertCache::open_sized(const std::vector<int64_t>& slot_bytes, int64_t n_l
slots_ = (int64_t) slot_bytes.size();
blob_ = mx;
off_ = std::move(off);
#if defined(STRATA_USE_HIP)
if (!ensure_blocking_staging((std::size_t) blob_, err)) {
close();
return false;
}
#endif
layer_next_.assign((size_t) (n_layers > 0 ? n_layers : 0), 0);
return true;
}
void ExpertCache::close() {
#if defined(STRATA_USE_HIP)
if (blocking_staging_) (void) cudaFreeHost(blocking_staging_);
blocking_staging_ = nullptr;
blocking_staging_bytes_ = 0;
#endif
off_.clear();
if (base_ != nullptr) {
cudaFree(base_);
@@ -256,7 +289,18 @@ bool ExpertCache::fill_slot_blocking(int32_t slot, const uint8_t* host_blob, std
err = "ExpertCache::fill_slot_blocking: the host blob is null";
return false;
}
#if defined(STRATA_USE_HIP)
// Bound HIP's pageable-source staging to one expert instead of repeatedly
// registering regions of the mmap. The blocking copy completes before reuse.
if (!blocking_staging_ || n > blocking_staging_bytes_) {
err = "ExpertCache::fill_slot_blocking: HIP staging buffer is too small";
return false;
}
std::memcpy(blocking_staging_, host_blob, n);
const cudaError_t e = cudaMemcpy(dst, blocking_staging_, n, cudaMemcpyHostToDevice);
#else
const cudaError_t e = cudaMemcpy(dst, host_blob, n, cudaMemcpyHostToDevice);
#endif
if (e != cudaSuccess) {
err = std::string("ExpertCache::fill_slot_blocking: ") + cudaGetErrorString(e);
return false;
File diff suppressed because it is too large. Load diff
+59 -17
View File
@@ -172,11 +172,13 @@ 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; }
namespace { bool g_kv_int8 = false, g_kv_q4 = false, g_kv_hybrid = false; }
void qsa_set_kv_int8(bool enabled) { g_kv_int8 = enabled; }
bool qsa_kv_int8() { return g_kv_int8; }
void qsa_set_kv_q4(bool enabled) { g_kv_q4 = enabled; }
bool qsa_kv_q4() { return g_kv_q4; }
void qsa_set_kv_hybrid(bool enabled) { g_kv_hybrid = enabled; }
bool qsa_kv_hybrid() { return g_kv_hybrid; }
uint64_t gdn_buffers_bytes(const ModelGeometry& g) { const int64_t C = g.ssm_conv_channels; const int64_t V = g.ssm_value_dim; const uint64_t parts[] = { q8k_bytes(g.n_embd),
// x_q8k
(uint64_t) (g.n_embd / 32) * 34,
@@ -507,10 +509,15 @@ KvPlan kv_plan(const QsaShapes& s, int64_t max_cells, int64_t ring_cells) {
}
return p;
}
uint64_t kv_pool_bytes(const QsaShapes& s, int64_t pages) {
uint64_t kv_pool_bytes(const QsaShapes& s, int64_t pages, bool hybrid, bool int8) {
if (hybrid) { // K8V4: the INT8 K half (codes + scales) plus the Q4_0 V half (kv_q4.hpp's rotation)
const uint64_t rows = (uint64_t) pages * s.page_size * s.n_head_kv;
return rows * (uint64_t) s.head_dim + rows * (uint64_t) (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2 +
rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim) + 64;
}
if (g_kv_q4) return (uint64_t) pages * s.page_size * strata::kernels::kv_q4_bytes_per_cell(s) + 64;
return g_kv_int8 ? (uint64_t) pages * s.page_size * strata::kernels::kv_q8_bytes_per_cell(s) + 64
: (uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim * 2 * 2;
return int8 ? (uint64_t) pages * s.page_size * strata::kernels::kv_q8_bytes_per_cell(s) + 64
: (uint64_t) pages * s.n_head_kv * s.page_size * s.head_dim * 2 * 2;
}
} // namespace
@@ -523,7 +530,8 @@ uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_ro
const QsaShapes s = qsa_shapes(g);
const KvPlan p = kv_plan(s, max_cells, ring_cells);
uint64_t n = 0;
n += kv_pool_bytes(s, p.slots) + 4 * 16; // K/V pools (the VRAM slots)
n += kv_pool_bytes(s, p.slots, g_kv_hybrid && ring_cells <= 0,
g_kv_int8 || g_kv_hybrid) + 4 * 16; // K/V pools (the VRAM slots)
n += (uint64_t) p.pages * 4; // page_table
if (p.mode == 1) n += strata::kernels::kv_stream_map_bytes(p.slots) + 6 * 16; // the residency map
n += (uint64_t) (s.idx_block - 1) * s.idx_dim * 4; // tail
@@ -544,14 +552,29 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
Cursor c{(uint8_t*) base};
st.kv_int8 = g_kv_int8 && !g_kv_q4;
st.kv_q4 = g_kv_q4;
// Hybrid K8V4, main layers only (the drafter's state is created with the globals toggled to INT8 -
// mtp.cpp). Streamed mode is refused outright; generate.cpp validates it too, this is the backstop.
if (g_kv_hybrid && ring_cells <= 0) {
if (p.mode == 1) {
std::fprintf(stderr, "strata: hybrid K8V4 KV does not support --kv-resident streaming\n");
return 0;
}
st.kv_hybrid = true;
st.kv_int8 = false;
st.kv_q4 = false;
}
st.kv_mode = p.mode;
st.n_slots = p.slots;
const uint64_t rows = (uint64_t) p.slots * s.n_head_kv * s.page_size; // VRAM rows: the slots
const uint64_t q4_row = strata::kernels::kv_q4_bytes_per_head((int) s.head_dim);
if (st.kv_q4) {
if (st.kv_hybrid) {
st.k_q = c.take<int8_t>(rows * s.head_dim);
st.k_scale = c.take<uint16_t>(rows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
st.v_q4 = c.take<uint8_t>(rows * q4_row);
} else if (st.kv_q4) {
st.k_q4 = c.take<uint8_t>(rows * q4_row);
st.v_q4 = c.take<uint8_t>(rows * q4_row);
} else if (g_kv_int8) {
} else if (st.kv_int8) { // st, not the global: the drafter's ring is INT8 under --kv k8v4 too
st.k_q = c.take<int8_t>(rows * s.head_dim);
st.v_q = c.take<int8_t>(rows * s.head_dim);
st.k_scale = c.take<uint16_t>(rows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
@@ -619,7 +642,7 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
if (st.kv_q4) {
st.host.k_q4 = hc.take<uint8_t>(hrows * q4_row);
st.host.v_q4 = hc.take<uint8_t>(hrows * q4_row);
} else if (g_kv_int8) {
} else if (st.kv_int8) {
st.host.k_q = hc.take<int8_t>(hrows * s.head_dim);
st.host.v_q = hc.take<int8_t>(hrows * s.head_dim);
st.host.k_scale = hc.take<uint16_t>(hrows * (s.head_dim / strata::kernels::KV_Q8_GROUP));
@@ -632,9 +655,10 @@ uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, Q
// THE ROPE TABLE IS BUILT ON THE HOST IN FLOAT64 and uploaded once, because the reference computes its
// frequencies in float64 and reproducing that on device means double-precision `pow`/`cos` that need not
// agree with the host's libm. A table shorter than the sequence would have the rotation read past it.
// The process's rope scaling (none by default) is INSIDE the table - the rotation kernels cannot tell.
if (share_rope == nullptr) {
std::vector<float> hc((size_t) max_cells * (s.n_rot / 2)), hs((size_t) max_cells * (s.n_rot / 2));
strata::kernels::build_rope_table((int) s.n_rot, strata::kernels::qsa_freq_base(), (int) max_cells,
strata::kernels::build_rope_table((int) s.n_rot, strata::kernels::rope_scaling(), (int) max_cells,
hc.data(), hs.data());
cudaMemcpy(st.cos_tab, hc.data(), hc.size() * 4, cudaMemcpyHostToDevice);
cudaMemcpy(st.sin_tab, hs.data(), hs.size() * 4, cudaMemcpyHostToDevice);
@@ -658,7 +682,11 @@ void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream) {
const QsaShapes s = qsa_shapes(g);
cudaStream_t cs = (cudaStream_t) stream;
const size_t rows = (size_t) st.n_slots * s.n_head_kv * s.page_size;
if (st.kv_q4) {
if (st.kv_hybrid) {
cudaMemsetAsync(st.k_q, 0, rows * s.head_dim, cs);
cudaMemsetAsync(st.k_scale, 0, rows * (s.head_dim / strata::kernels::KV_Q8_GROUP) * 2, cs);
cudaMemsetAsync(st.v_q4, 0, rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), cs);
} else if (st.kv_q4) {
cudaMemsetAsync(st.k_q4, 0, rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), cs);
cudaMemsetAsync(st.v_q4, 0, rows * strata::kernels::kv_q4_bytes_per_head((int) s.head_dim), cs);
} else if (st.kv_int8) {
@@ -682,7 +710,8 @@ void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream) {
strata::kernels::QsaAttnPools qsa_attn_pools(const QsaState& st) {
strata::kernels::QsaAttnPools pools;
pools.page_table = st.page_table;
if (st.kv_q4) { pools.k_q4 = st.k_q4; pools.v_q4 = st.v_q4; }
if (st.kv_hybrid) { pools.k_q = st.k_q; pools.k_scale = st.k_scale; pools.v_q4 = st.v_q4; }
else if (st.kv_q4) { pools.k_q4 = st.k_q4; pools.v_q4 = st.v_q4; }
else if (st.kv_int8) { pools.k_q = st.k_q; pools.v_q = st.v_q; pools.k_scale = st.k_scale; pools.v_scale = st.v_scale; }
else { pools.k_pool = st.k_pool; pools.v_pool = st.v_pool; }
return pools;
@@ -815,7 +844,7 @@ const auto normalize_rotate = [&](float* data, const WeightRef* norm, int rows,
try {
if (native_qsa_enabled()) native_qsa_rms_norm_weighted(data, (const float*) norm->data, data, cols, rows, RMS_EPS, stream);
else rms_norm_weighted(data, (const float*) norm->data, rows, cols, RMS_EPS, stream);
if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, (float) qsa_freq_base(), st.pos_dev, stream);
if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, rope_scaling(), st.pos_dev, stream);
else rope_neox_apply(data, data, rows, cols, (int) s.n_rot, st.cos_tab, st.sin_tab, st.pos_dev, stream);
return true;
} catch (const std::exception& error) {
@@ -863,14 +892,21 @@ if (!gemv_quantized(*w_attnk, p_k, f_k, b.x_q8_0, b.x_q8k, b.kcur, g.n_embd, g.n
// EVERY ENTRY POINT FROM HERE ON IS THE CAPTURABLE ONE: the per-token counts come from `st.step` and every
// launch is sized from a capacity in `st`/`b`, so this sequence can be captured and replayed. The
// host-scalar wrappers would be correct here today and silently wrong in a graph.
if (st.kv_q4) {
if (st.kv_hybrid) {
// K8V4: only V is rotated (kv_q4.hpp's H); the scores pair unrotated q with unrotated INT8 K, and the
// output - a mix of rotated values - is rotated back after attention. Each append/gather call folds the
// unused half's lanes onto the used pool (a bit-identical duplicate write), so no kernel variants exist.
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)
strata::kernels::fwht256_inplace_cuda(b.vcur, g.n_head_kv, stream);
strata::kernels::kv_append_q4_step(st.k_q4, st.v_q4, st.page_table, st.step, b.kcur, b.vcur, s, stream, &st.host);
} else if (st.kv_int8) kv_append_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, st.step, b.kcur, b.vcur, s, stream, &st.host); else kv_append_step(st.k_pool, st.v_pool, st.page_table, st.step, b.kcur, b.vcur, s, stream, &st.host); { const uint64_t nvk = (uint64_t) g.n_head_kv * g.head_dim; const uint64_t base = (uint64_t) 2 * g.n_embd + 2 * g.hc + (uint64_t) g.n_head * g.head_dim + 2 * nvk + 8; if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.k_pool, base, nvk / 2, stream); if (!st.kv_int8) dump_slot(dump, g, layer, (const float*) st.v_pool, base + nvk / 2, nvk / 2, stream); dump_slot(dump, g, layer, b.vcur, base + nvk, nvk, stream); dump_slot(dump, g, layer, b.kcur, base + 2 * nvk, nvk, stream); } { const QsaIndexerBuffers ib{st.idx_tail, st.idx_dead, st.idx_pooled, st.idx_block_pos}; if (native_qsa_indexer_enabled()) {
} 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()) {
try {
native_qsa_indexer_append(b.idx_raw, st.step + kStepPos, pos_base,
(const float*) w_ikn->data, RMS_EPS, ib, s, st.max_cells, (float) qsa_freq_base(), stream);
(const float*) w_ikn->data, RMS_EPS, ib, s, st.max_cells, rope_scaling(), stream);
} catch (const std::exception& error) { err = v.name("native_indexer") + ": " + error.what(); return false; }
} else indexer_key_append(b.idx_raw, st.pos_dev, pos_base, (const float*) w_ikn->data, RMS_EPS, ib, s,
st.cos_tab, st.sin_tab, stream); }
@@ -906,7 +942,13 @@ int64_t max_blocks = (st.max_cells / s.idx_block) + 2;
const strata::kernels::QsaAttnPools pools = qsa_attn_pools(st);
strata::kernels::qsa_decode_attn_step(b.qcur, pools, b.ids, st.step, cap, s, b.attn_scratch, b.attn, stream);
} else {
if (st.kv_q4) strata::kernels::kv_gather_q4_step(st.k_q4, st.v_q4, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream);
if (st.kv_hybrid) {
kv_gather_q8_step(st.k_q, st.k_q, st.k_scale, st.k_scale, st.page_table, b.ids, st.step, cap, s,
b.k_scratch, b.k_scratch, stream);
strata::kernels::kv_gather_q4_step(st.v_q4, st.v_q4, st.page_table, b.ids, st.step, cap, s,
b.v_scratch, b.v_scratch, stream);
}
else if (st.kv_q4) strata::kernels::kv_gather_q4_step(st.k_q4, st.v_q4, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream);
else if (st.kv_int8) kv_gather_q8_step(st.k_q, st.v_q, st.k_scale, st.v_scale, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); else kv_gather_step(st.k_pool, st.v_pool, st.page_table, b.ids, st.step, cap, s, b.k_scratch, b.v_scratch, stream); if (native_flash_attn_short) {
if (st.max_cells < 1 || st.max_cells > 256 || !st.attention_status || !st.host_step) {
err = v.name("native_flash_attn") + ": short adapter requires context <=256 and persistent status storage";
@@ -922,7 +964,7 @@ int64_t max_blocks = (st.max_cells / s.idx_block) + 2;
}
} else qsa_attend_step(b.qcur, b.k_scratch, b.v_scratch, st.step, cap, s, b.attn, nullptr, stream);
}
if (st.kv_q4) strata::kernels::fwht256_inplace_cuda(b.attn, g.n_head, stream); // the output back: H is self-inverse
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
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.
+4 -1
View File
@@ -65,7 +65,10 @@ int main(int argc, char** argv) {
const strata::core::LoadStats st =
strata::core::load_experts(path, dst, blob_bytes, blobs_per_layer, layers, threads, chunk);
if (st.seconds < 0) return 1;
if (!st.ok) {
std::fprintf(stderr, "loading the experts failed: %s\n", st.error.empty() ? "unknown" : st.error.c_str());
return 1;
}
std::printf("loaded %.3f GiB in %.3f s with %d threads and %llu MiB chunks -> %.2f GiB/s\n",
(double) st.bytes / (1024.0 * 1024 * 1024), st.seconds, threads,
+165 -26
View File
@@ -1,5 +1,6 @@
// src/core/mtp.cpp - see include/strata/core/mtp.hpp.
#include "strata/core/mtp.hpp"
#include "strata/core/coupled_draft.hpp"
#include "strata/core/on_device.hpp"
#include "strata/core/native_head.hpp"
@@ -69,13 +70,31 @@ strata::kernels::QsaShapes shapes_of(const ModelGeometry& g) {
return s;
}
// 64-bit seek/tell on a `FILE*`: `fseek`/`ftell` take a 32-bit `long` on Windows and would wrap past 2 GiB.
#if defined(_WIN32)
#define STRATA_FILE_SEEK64(f, o, w) _fseeki64((f), (long long) (o), (w))
#define STRATA_FILE_TELL64(f) _ftelli64(f)
#else
#define STRATA_FILE_SEEK64(f, o, w) fseeko((f), (off_t) (o), (w))
#define STRATA_FILE_TELL64(f) ftello(f)
#endif
bool read_file(const std::string& path, std::vector<uint8_t>& out) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return false;
const std::streamsize n = f.tellg();
f.seekg(0);
// Loader fix (0.1.15+loaderfix.2): `ifstream::read` reaches the disk as 4095-byte reads on MSVC - the same
// split `load_experts_ranges` had - so the drafter's 111 MiB dense blob paid 4 KiB per operation on a cold
// start. `fread` passes a request bigger than the stream buffer straight to `ReadFile()`.
FILE* f = std::fopen(path.c_str(), "rb");
if (f == nullptr) return false;
// A `FILE*` has no destructor and the short-read path below returns early: the guard closes on every path.
struct Closer {
FILE* f;
~Closer() { if (f != nullptr) std::fclose(f); }
} closer{f};
if (STRATA_FILE_SEEK64(f, 0, SEEK_END) != 0) return false;
const long long n = (long long) STRATA_FILE_TELL64(f);
if (n < 0 || STRATA_FILE_SEEK64(f, 0, SEEK_SET) != 0) return false;
out.resize((size_t) n);
return (bool) f.read((char*) out.data(), n);
return n == 0 || std::fread(out.data(), 1, (size_t) n, f) == (size_t) n;
}
} // namespace
@@ -87,6 +106,14 @@ MtpDrafter::~MtpDrafter() {
if (pf_dev_) cudaFree(pf_dev_);
for (auto& e : round_exec_) if (e) cudaGraphExecDestroy(e);
for (auto& e : step_exec_) if (e) cudaGraphExecDestroy(e);
for (auto& e : round_exec_c_) if (e) cudaGraphExecDestroy(e);
for (auto& e : step_exec_c_) if (e) cudaGraphExecDestroy(e);
if (cparams_) cudaFree(cparams_);
if (cring_) cudaFree(cring_);
if (dinv_) cudaFree(dinv_);
if (cscratch_) cudaFree(cscratch_);
if (h_cparams_) cudaFreeHost(h_cparams_);
if (h_chist_) cudaFreeHost(h_chist_);
if (cs_) cudaStreamDestroy(cs_);
if (dense_) cudaFree(dense_);
if (experts_) cudaFree(experts_);
@@ -120,6 +147,9 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
max_t_ = max_t;
rt_dir_ = rt_dir;
if (max_t < 1 || max_t > strata::kernels::kVerifyMaxT) { err = "mtp: max_t out of range"; return false; }
// Loader fix (0.1.15+loaderfix.2): the two reads below are the whole “drafter files” cost; reporting
// them apart from the rest of the stage is what makes the next regression visible.
const auto t_files = std::chrono::steady_clock::now();
// ---- the index and the dense weights
{
std::ifstream idx(rt_dir + "/dense.txt");
@@ -150,13 +180,19 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
// ---- the 512 routed experts, one blob each
{
const uint64_t bytes = (uint64_t) g.n_expert * strata::kernels::cpu::BLOB;
std::ifstream f(rt_dir + "/experts.bin", std::ios::binary);
if (!f) { err = "mtp: cannot open experts.bin"; return false; }
// Loader fix (0.1.15+loaderfix.2): each 64 MiB read below reached the disk as ~16k 4095-byte reads under
// MSVC's `basic_filebuf::xsgetn`, which is what made 675 MiB of drafter experts take minutes.
FILE* f = std::fopen((rt_dir + "/experts.bin").c_str(), "rb");
if (f == nullptr) { err = "mtp: cannot open experts.bin"; return false; }
struct Closer {
FILE* f;
~Closer() { if (f != nullptr) std::fclose(f); }
} closer{f};
if (cudaMalloc((void**) &experts_, bytes) != cudaSuccess) { err = "mtp: the 512 experts do not fit in VRAM"; return false; }
std::vector<uint8_t> chunk(64u << 20);
for (uint64_t off = 0; off < bytes;) {
const uint64_t n = std::min<uint64_t>(chunk.size(), bytes - off);
if (!f.read((char*) chunk.data(), (std::streamsize) n)) { err = "mtp: experts.bin is truncated"; return false; }
if (std::fread(chunk.data(), 1, (size_t) n, f) != (size_t) n) { err = "mtp: experts.bin is truncated"; return false; }
cudaMemcpy(experts_ + off, chunk.data(), n, cudaMemcpyHostToDevice);
off += n;
}
@@ -169,14 +205,20 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
// ---- the layer's own K/V (dense attention: no indexer state is read)
const strata::kernels::QsaShapes s = shapes_of(g);
const int64_t max_cells = ss.qsa_states[0].max_cells;
const int64_t max_cells = ss.qsa_states[ss.qsa_primary()].max_cells;
// KV streaming: the drafter only reads its last `window` cells, so with streaming on its K/V is a ring of the
// window (plus the cells a round writes ahead of its queries) over a host copy, refilled on a resume. The host copy
// is pinned after the expert arena has pinned what it could: if it does not fit, the K/V stays whole in VRAM.
int64_t ring = (window > 0 && window < max_cells) ? window + 4 * (int64_t) max_t + 64 : 0;
// K8V4 never applies to the drafter: its own attention paths (below, and verify.cpp) handle whole formats
// only, whatever ring shape it takes (0, a window, or the -1 fully-resident fallback).
const bool kv_hybrid_was = qsa_kv_hybrid();
const bool kv_int8_was = qsa_kv_int8();
qsa_set_kv_hybrid(false);
if (kv_hybrid_was) qsa_set_kv_int8(true); // the drafter under --kv k8v4: plain INT8
uint64_t sb = qsa_state_bytes(g, max_cells, false, ring);
if (cudaMalloc(&state_arena_, sb) != cudaSuccess) { err = "mtp: the K/V state does not fit"; return false; }
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[0], ring) == 0) {
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[ss.qsa_primary()], ring) == 0) {
if (st_.kv_mode == 0) { err = "mtp: state init failed"; return false; }
std::fprintf(stderr, "strata mtp: no pinned RAM left for the draft layer's K/V copy; keeping it in VRAM\n");
cudaGetLastError();
@@ -185,8 +227,10 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
ring = -1; // fully resident
sb = qsa_state_bytes(g, max_cells, false, ring);
if (cudaMalloc(&state_arena_, sb) != cudaSuccess) { err = "mtp: the K/V state does not fit"; return false; }
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[0], ring) == 0) { err = "mtp: state init failed"; return false; }
if (qsa_state_init(g, max_cells, state_arena_, st_, &ss.qsa_states[ss.qsa_primary()], ring) == 0) { err = "mtp: state init failed"; return false; }
}
qsa_set_kv_int8(kv_int8_was);
qsa_set_kv_hybrid(kv_hybrid_was);
qsa_state_zero(st_, g, nullptr);
cudaDeviceSynchronize();
vram_ += sb;
@@ -248,12 +292,86 @@ bool MtpDrafter::load(const std::string& rt_dir, const ModelGeometry& g, Session
cudaMemcpy(ident_, id.data(), id.size() * 4, cudaMemcpyHostToDevice);
}
if (cudaStreamCreateWithFlags(&cs_, cudaStreamNonBlocking) != cudaSuccess) { err = "mtp: stream"; return false; }
std::fprintf(stderr, "strata mtp: draft layer loaded, %.0f MiB of VRAM (experts %.0f, dense %.0f)\n",
const double files_s = std::chrono::duration<double>(std::chrono::steady_clock::now() - t_files).count();
std::fprintf(stderr, "strata mtp: draft layer loaded, %.0f MiB of VRAM (experts %.0f, dense %.0f), files read in %.2f s (%.0f MiB/s)\n",
(double) vram_ / 1048576.0, (double) g.n_expert * strata::kernels::cpu::BLOB / 1048576.0,
(double) tensors_.back().off / 1048576.0);
(double) tensors_.back().off / 1048576.0, files_s,
files_s > 0 ? ((double) g.n_expert * strata::kernels::cpu::BLOB + (double) tensors_.back().off) /
1048576.0 / files_s : 0.0);
return true;
}
uint64_t MtpDrafter::bind_bytes(uint64_t head_row_bytes, int64_t n_vocab) const {
uint64_t bytes = head_logits_ ? 0 : (uint64_t) max_t_ * (uint64_t) n_vocab * sizeof(float);
if (dhead_ == nullptr) {
if (FILE* f = std::fopen((rt_dir_ + "/draft_vocab.bin").c_str(), "rb")) {
std::fseek(f, 0, SEEK_END);
const long size = std::ftell(f);
std::fclose(f);
if (size >= 4 && size % 4 == 0) bytes += (uint64_t) (size / 4) * head_row_bytes + (uint64_t) size;
}
}
// coupled draft sampling (STRATA_SPEC_COUPLED=1 only): an upper bound - the id -> subset map, the penalty ring,
// the split scratch (64 lists of 64 entries at most) and the parameters
if (coupled_draft_env() && cparams_ == nullptr)
bytes += (uint64_t) n_vocab * 4 + (uint64_t) (kCoupledHistCap + max_t_) * 4 + 64ull * 64ull * 8ull + 4096;
return bytes;
}
bool MtpDrafter::setup_coupled(std::string& err) {
const int64_t nv = dhead_ != nullptr ? n_dvocab_ : n_vocab_;
const size_t scratch = strata::kernels::coupled_draft_scratch_bytes((int) nv);
if (scratch == 0) {
std::fprintf(stderr, "strata mtp: STRATA_SPEC_COUPLED: %lld draft logits are too wide for the coupled sampler; "
"argmax drafts\n", (long long) nv);
return true;
}
const size_t ring = (size_t) (kCoupledHistCap + max_t_) * sizeof(int32_t);
if (cudaMalloc((void**) &cparams_, sizeof(strata::kernels::SamplerParams)) != cudaSuccess ||
cudaMalloc((void**) &cring_, ring) != cudaSuccess || cudaMalloc(&cscratch_, scratch) != cudaSuccess ||
!mapped(sizeof(strata::kernels::SamplerParams), (void**) &h_cparams_, (void**) &m_cparams_) ||
!mapped((size_t) kCoupledHistCap * sizeof(int32_t), (void**) &h_chist_, (void**) &m_chist_)) {
err = "mtp: the coupled draft sampler's buffers do not fit";
return false;
}
cudaMemset(cring_, 0xff, ring); // -1: no token
std::fill(h_chist_, h_chist_ + kCoupledHistCap, -1);
vram_ += sizeof(strata::kernels::SamplerParams) + ring + scratch;
if (dhead_ != nullptr) { // token id -> subset index (-1: not in the draft head), for the penalties
std::vector<int32_t> sub((size_t) n_dvocab_), inv((size_t) n_vocab_, -1);
cudaMemcpy(sub.data(), dvocab_, sub.size() * sizeof(int32_t), cudaMemcpyDeviceToHost);
for (size_t i = 0; i < sub.size(); ++i)
if (sub[i] >= 0 && sub[i] < n_vocab_ && inv[(size_t) sub[i]] < 0) inv[(size_t) sub[i]] = (int32_t) i;
if (cudaMalloc((void**) &dinv_, inv.size() * sizeof(int32_t)) != cudaSuccess) {
err = "mtp: the coupled draft sampler's token map does not fit";
return false;
}
cudaMemcpy(dinv_, inv.data(), inv.size() * sizeof(int32_t), cudaMemcpyHostToDevice);
vram_ += inv.size() * sizeof(int32_t);
}
if (cudaDeviceSynchronize() != cudaSuccess) {
err = std::string("mtp: coupled setup: ") + cudaGetErrorString(cudaGetLastError());
return false;
}
coupled_ok_ = true;
std::fprintf(stderr, "strata mtp: coupled draft sampling on (STRATA_SPEC_COUPLED): sampled requests draft with the "
"target's chain and Philox draw over %lld tokens\n", (long long) nv);
return true;
}
void MtpDrafter::set_draft_sampling(const strata::kernels::SamplerParams& sp) {
coupled_active_ = coupled_ok_ && !sp.greedy && sp.temperature > 0.0f;
if (!coupled_active_) return;
*h_cparams_ = sp; // read by the next round graph (after the previous one has synced)
std::fill(h_chist_, h_chist_ + kCoupledHistCap, -1);
}
void MtpDrafter::set_draft_history(const int32_t* tail, int64_t n_tail, int32_t next) {
if (!coupled_active_) return;
const int h = coupled_hist_len(h_cparams_->penalty_last_n, kCoupledHistCap);
coupled_hist_base(tail, n_tail, next, h, h_chist_ + (kCoupledHistCap - h));
}
bool MtpDrafter::bind(const WeightTable& wt, const NativeHead* head, const float* window_R, std::string& err) {
const OnDevice on_device(device_);
wt_ = &wt;
@@ -287,6 +405,7 @@ bool MtpDrafter::bind(const WeightTable& wt, const NativeHead* head, const float
(double) (n_dvocab_ * row_bytes) / 1048576.0);
}
}
if (coupled_draft_env() && cparams_ == nullptr && !setup_coupled(err)) return false;
return true;
}
@@ -344,7 +463,7 @@ bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::stri
// ---- attention: K/V into the layer's own cache, then (full) dense attention over every cell
auto norm_rope = [&](float* data, const float* gamma, int rows, int cols, const int32_t* p) {
native_qsa_rms_norm_weighted(data, gamma, data, cols, rows, EPS, cs);
if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, (float) qsa_freq_base(), p, cs);
if (native_rope_enabled()) native_rope_apply(data, data, rows, cols, (int) s.n_rot, rope_scaling(), p, cs);
else rope_neox_apply(data, data, rows, cols, (int) s.n_rot, st_.cos_tab, st_.sin_tab, p, cs);
};
native_quantize_q8_1(mixed_, xq_, (int) N, T, cs);
@@ -437,6 +556,13 @@ bool MtpDrafter::record_forward(int T, int step_row0, cudaStream_t cs, std::stri
const bool sub = dhead_ != nullptr;
const int64_t nv = sub ? n_dvocab_ : n_vocab_;
native_mmvq(head_->type(), sub ? dhead_ : head_->weights(), xq_, head_logits_, (int) N, (int) nv, T, cs);
if (coupled_rec_) {
// coupled draft sampling: the target's chain and Philox draw for the row that will verify this draft
// (counter = this cell + 1, from its step record), penalties over the ring; T == 1 (full layer)
coupled_draft_sample(head_logits_, (int) nv, sub ? dvocab_ : nullptr, sub ? dinv_ : nullptr, (int) n_vocab_,
cparams_, cring_, kCoupledHistCap, coupled_j_, step, cscratch_, out_ids_, probs_, cs);
return true;
}
SamplerParams sp;
sp.greedy = true;
sp.temperature = 0.0f;
@@ -489,12 +615,15 @@ bool MtpDrafter::capture_prefill_dev(int T, std::string& err) {
return finish_capture(cs_, ok, prefill_dev_exec_[T], "prefill (device inputs)", err);
}
bool MtpDrafter::capture_round(int T, std::string& err) {
if (round_exec_[T]) return true;
bool MtpDrafter::capture_round(int T, bool coupled, std::string& err) {
cudaGraphExec_t& exec = coupled ? round_exec_c_[T] : round_exec_[T];
if (exec) return true;
using namespace strata::kernels;
const int64_t HCN = g_->hc * g_->n_embd;
if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { err = "mtp: begin capture"; return false; }
bool ok = true;
// coupled: the request's chain and the penalty history's base, for this round's drafts
if (coupled) coupled_draft_stage(m_cparams_, m_chist_, cparams_, cring_, kCoupledHistCap, cs_);
copy_i32_from_mapped(tok_, m_tok_, T, cs_);
copy_i32_from_mapped(step_, m_step_, (int64_t) 2 * T * 4, cs_);
copy_i32_from_mapped(pos_, m_pos_, (int64_t) 2 * T * g_->n_head, cs_);
@@ -509,25 +638,32 @@ bool MtpDrafter::capture_round(int T, std::string& err) {
if (ok) {
copy_i32_from_mapped(step_ + ra * 4, m_step_ + ra * 4, 4, cs_);
copy_i32_from_mapped(pos_ + ra * g_->n_head, m_pos_ + ra * g_->n_head, g_->n_head, cs_);
coupled_rec_ = coupled;
coupled_j_ = 0;
ok = record_forward(1, ra, cs_, err);
coupled_rec_ = false;
}
if (ok) mtp_select(R_, HCN, out_ids_, row_ + 1, Rin_, tok_, m_out_, 0, cs_, probs_, m_prob_);
return finish_capture(cs_, ok, round_exec_[T], "round", err);
return finish_capture(cs_, ok, exec, coupled ? "round (coupled)" : "round", err);
}
// Chain step j (1..max_t-2): one row at the cell staged in step row `max_t + j - 1`, from the previous step's
// residual and token (left in Rin_[0] / tok_[0] by mtp_select); draft j and its probability to the mapped outputs.
bool MtpDrafter::capture_step(int j, std::string& err) {
if (step_exec_[j]) return true;
bool MtpDrafter::capture_step(int j, bool coupled, std::string& err) {
cudaGraphExec_t& exec = coupled ? step_exec_c_[j] : step_exec_[j];
if (exec) return true;
using namespace strata::kernels;
const int64_t HCN = g_->hc * g_->n_embd;
const int row = max_t_ + j - 1;
if (cudaStreamBeginCapture(cs_, cudaStreamCaptureModeThreadLocal) != cudaSuccess) { err = "mtp: begin capture"; return false; }
copy_i32_from_mapped(step_ + row * 4, m_step_ + row * 4, 4, cs_);
copy_i32_from_mapped(pos_ + row * g_->n_head, m_pos_ + row * g_->n_head, g_->n_head, cs_);
coupled_rec_ = coupled;
coupled_j_ = j;
bool ok = record_forward(1, row, cs_, err);
coupled_rec_ = false;
if (ok) mtp_select(R_, HCN, out_ids_, row_ + 1, Rin_, tok_, m_out_, j, cs_, probs_, m_prob_);
return finish_capture(cs_, ok, step_exec_[j], "step", err);
return finish_capture(cs_, ok, exec, coupled ? "step (coupled)" : "step", err);
}
void MtpDrafter::kv_restore(int64_t upto) {
@@ -633,7 +769,8 @@ bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t*
float* probs, float min_p, int* n_drafts) {
const OnDevice on_device(device_);
if (T < 1 || T > max_t_ || a < 0 || a >= T) { err = "mtp: draft arguments out of range"; return false; }
if (!capture_round(T, err)) return false;
const bool cp = coupled_active_; // coupled draft sampling for this request: its own graphs
if (!capture_round(T, cp, err)) return false;
const Clock::time_point t0 = Clock::now();
const int64_t NH = g_->n_head;
auto put = [&](int row, int64_t cell) {
@@ -647,11 +784,12 @@ bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t*
h_tok_[t] = tokens[t];
put(t, p + t);
}
put(2 * max_t_ - 1, p + a);
put(2 * max_t_ - 1, coupled_draft_cell(p, a, 0)); // p + a: draft 0's cell
h_row_[0] = a;
h_row_[1] = 0;
std::atomic_thread_fence(std::memory_order_seq_cst);
if (cudaGraphLaunch(round_exec_[T], cs_) != cudaSuccess || cudaStreamSynchronize(cs_) != cudaSuccess) {
if (cudaGraphLaunch(cp ? round_exec_c_[T] : round_exec_[T], cs_) != cudaSuccess ||
cudaStreamSynchronize(cs_) != cudaSuccess) {
err = std::string("mtp draft: ") + cudaGetErrorString(cudaGetLastError());
return false;
}
@@ -661,10 +799,11 @@ bool MtpDrafter::draft(int T, const int32_t* tokens, int64_t p, int a, int32_t*
int n = 1;
// the chain continues while the last draft is likely enough to be verified
for (int j = 1; j < std::min(max_t_ - 1, max_drafts_) && pj >= min_p; ++j) {
if (!capture_step(j, err)) return false;
put(max_t_ + j - 1, p + a + j);
if (!capture_step(j, cp, err)) return false;
put(max_t_ + j - 1, coupled_draft_cell(p, a, j)); // p + a + j; coupled: drawn at counter cell + 1
std::atomic_thread_fence(std::memory_order_seq_cst);
if (cudaGraphLaunch(step_exec_[j], cs_) != cudaSuccess || cudaStreamSynchronize(cs_) != cudaSuccess) {
if (cudaGraphLaunch(cp ? step_exec_c_[j] : step_exec_[j], cs_) != cudaSuccess ||
cudaStreamSynchronize(cs_) != cudaSuccess) {
err = std::string("mtp draft step: ") + cudaGetErrorString(cudaGetLastError());
return false;
}
+3 -3
View File
@@ -16,9 +16,9 @@ namespace {
bool eligible(const strata::TensorInfo& tensor, bool include_ple_key) {
const auto& name = tensor.name;
if (name.rfind("blk.", 0) != 0) return false;
// The native PLE kernel accepts Q2_0. Other quantized keys can be converted to BF16 by iq_pack;
// keep their packed bytes resident instead of overriding them with an unsupported native key.
if (name == "blk.1.ple_key.weight") return include_ple_key && tensor.type == 42; // 42 = Q2_0
// Match the native PLE kernel: Q2_0, IQ3_XXS and IQ4_XS. Other keys retain the packed BF16 fallback.
if (name == "blk.1.ple_key.weight")
return include_ple_key && (tensor.type == 42 || tensor.type == 18 || tensor.type == 23);
static const char* suffixes[] = {".attn_qkv.weight", ".attn_gate.weight", ".ssm_out.weight",
".attn_q.weight", ".attn_k.weight", ".attn_v.weight", ".attn_output.weight",
".ffn_gate_shexp.weight", ".ffn_up_shexp.weight", ".ffn_down_shexp.weight"};
+25 -9
View File
@@ -5,6 +5,7 @@
#include <cuda_runtime.h>
#include <climits>
#include <cstdio>
#include <cstring>
#include <exception>
@@ -102,6 +103,7 @@ const NativeEmbed* native_embed() { return g_embed; }
NativeEmbed::~NativeEmbed() {
if (host_) cudaFreeHost(host_);
else if (dev_) cudaFree(const_cast<void*>(dev_)); // the VRAM fallback below
}
bool NativeEmbed::load(const std::string& path, int64_t n_embd, int64_t n_vocab, std::string& err) {
@@ -119,17 +121,31 @@ bool NativeEmbed::load(const std::string& path, int64_t n_embd, int64_t n_vocab,
row_ = strata::kernels::iq_row_bytes((int) t->type, n_embd);
bytes_ = (uint64_t) row_ * (uint64_t) n_vocab;
if (cudaHostAlloc(&host_, bytes_, cudaHostAllocMapped | cudaHostAllocPortable) != cudaSuccess) {
// Under WSL2 the driver's pinned/mapped host budget (~1 GiB) can be spent by the GPU contexts
// themselves (three cards). The table is only gathered from, so keep it in the current device's VRAM
// instead: it costs its size there and reads faster than over PCIe.
cudaGetLastError();
host_ = nullptr;
err = "native embedding: cannot pin " + std::to_string(bytes_ >> 20) + " MiB";
return false;
void* d = nullptr;
if (cudaMalloc(&d, bytes_) != cudaSuccess ||
cudaMemcpy(d, gguf.tensor_data(*t), bytes_, cudaMemcpyHostToDevice) != cudaSuccess) {
if (d) cudaFree(d);
cudaGetLastError();
err = "native embedding: cannot pin " + std::to_string(bytes_ >> 20) + " MiB, nor place it in VRAM";
return false;
}
std::fprintf(stderr, "strata: native embedding: cannot pin %llu MiB, kept in VRAM instead\n",
(unsigned long long) (bytes_ >> 20));
dev_ = d;
} else {
std::memcpy(host_, gguf.tensor_data(*t), bytes_);
void* d = nullptr;
if (cudaHostGetDevicePointer(&d, host_, 0) != cudaSuccess) {
err = "native embedding: no device alias for the mapped table";
return false;
}
dev_ = d;
}
std::memcpy(host_, gguf.tensor_data(*t), bytes_);
void* d = nullptr;
if (cudaHostGetDevicePointer(&d, host_, 0) != cudaSuccess) {
err = "native embedding: no device alias for the mapped table";
return false;
}
dev_ = d;
type_ = (int) t->type;
n_embd_ = n_embd;
n_vocab_ = n_vocab;
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more