Compare commits

...
197 Commits
Author SHA1 Message Date
Niko1221 870aedd9e1 [Engine] SYCL: the prompt attention batch is budgeted where it is allocated, and stays 32 by default
The scratch of the FP32 prompt attention is batch x chunks x n_head x 258 floats (about 0.41 GB at 32 and 1.65 GB at 128 on
Flash-Next), independent of the prompt chunk.  Prefill::init allocated it from STRATA_ATTN_BATCH (128 since 09524eb47) while the
estimate that sizes the expert cache and the prompt path's loan (bytes_needed_impl) still counted 32, so about 1.2 GB was never
reserved and a single 32 GB Arc Pro B65 with the config setup writes failed its first request ("device buffers for a chunk of 512
tokens do not fit (2098 of 32656 MiB free)").  Both now use one function (attn_batch_for), and the batch is capped at the chunk's
token count, since a chunk never runs more queries than it has.

The default goes back to 32: with the budget honest, 128 would take 1.2 GB from the expert cache on every Intel card for a
1.1-1.3% faster prompt on two B65s.  STRATA_ATTN_BATCH still sets it (1..1024), and is now counted.
2026-10-29 18:13:26 +01:00
Niko1221 36f6663c25 Version 0.2.8: Terminal and app 0.2.8, engine and Control 0.1.54 (every Qwen and DeepSeek package, Metal and SYCL included) 2026-10-29 18:04:23 +01:00
Niko1221 1bf946eafa Merge f-028-monitor: the Monitor keeps the app's own GPU readings where the engine has none (AMD on Windows), reads Windows AMD temperature, and drops non-GPU display chips on Linux (#3727) 2026-10-29 18:04:10 +01:00
Niko1221 5e8f47cfff Monitor: AMD cards keep their readings while an engine runs, Windows AMD temperature, no remote-console chip as a GPU (#3727) 2026-10-29 17:33:56 +01:00
Niko1221 18200cffaf Merge f-028-arcb65-pipe: pipelined B-series (two Battlemage stages) is the default 2026-10-29 17:25:33 +01:00
Niko1221 cf059188ca Merge f-028-arcb65: SYCL lane-cell prompt attention for Battlemage only, and the Arc B-series work 2026-10-29 17:22:06 +01:00
Niko1221 b119f40f08 window_limits_test: setenv/unsetenv for MSVC (the Windows test build stopped on it) 2026-10-29 17:22:02 +01:00
Niko1221 8d5be82566 docs: two Arc B-series cards decode with the pipelined windows by default
INTEL_ARC.md: one bullet with the numbers (2x Arc Pro B65: decode +17% to +23% over the serial --spec 4 setup, 5 pairs of 5),
what stays as before, and the two switches; one row in the tested cards table.  MULTI_GPU.md: the Default line of
"Pipeline windows" now names the Intel Arc case beside AMD RDNA4 and NVIDIA.
2026-10-29 16:53:58 +01:00
Niko1221 74e1f86442 Setup and the launcher write --spec 2 for exactly two Arc B-series cards
On two Battlemage cards the engine pipelines the two cards' verify windows by default, and then answers as a 2-token window
whatever --spec says (pipelined --spec 4 equals serial --spec 2 in 100 of 100 agent requests; serial --spec 4 differs in 3).
So the generated config says --spec 2 there: the same answers, and about 20% faster than the old --spec 4.

strata_control/intel_spec.py holds the rule: the cards are found by PCI device id (the xe driver and an id in 0xE200-0xE2FF,
the id the engine's architecture enum comes from), never by name.  It changes only setup's own default "--spec 4", only
with the draft layer on, and only for exactly two such cards.  A --spec in "user_args", any other value, one card, an
A-series card in the pair, three cards and "mtp": false are left alone.  Applied in three places: sycl/setup_intel.py when it
writes the run config of a layer split (the copy in "args_written" follows, so a rerun does not take it for a hand edit),
the launcher's derive_config (before the person's --set options, which win), and the Intel server's engine_args at every
start (a config an older setup wrote).  Tests in tests/sycl/test_intel_two_card_spec.py.
2026-10-29 15:52:37 +01:00
Niko1221 a6fd788567 [Engine] SYCL: --pipeline-windows 2 is the default on exactly two Arc Battlemage cards
When --pipeline-windows is not given, the engine turns it on for --serve with a layer split of exactly two stages whose
GPUs are both Battlemage (Xe2) cards by architecture id (strata::intel_gpu_gen), with the MTP drafter (--spec 2 or more),
no --batch, no --peer-device and no helper caches.  A single card, an Arc A-series card, a mix, three or more stages
and any other vendor stay serial.  --pipeline-windows N and STRATA_PIPELINE_AUTO=0 override it, and one log line says it
is on.  The condition is a host-only function (strata/pipeline_auto.hpp) with a test that needs no GPU.

Measured on 2x Arc Pro B65: a 1 h mixed serve (1,082 requests) with 0 errors, 0 token differences against the serial run
of the same requests and flat VRAM; decode +17% to +28% over the serial --spec 4 default.  The pipelined windows answer as
a 2-token window, so setup and the launcher write --spec 2 for such a config (next commit).
2026-10-29 15:52:37 +01:00
Niko1221 32f0c44f08 Merge f-028-par 2026-10-29 15:11:01 +01:00
Niko1221 93dd9b421d Merge f-028-macturns 2026-10-29 15:11:00 +01:00
Niko1221 92a867be17 Merge f-arc-cpumsg 2026-10-29 15:11:00 +01:00
Niko1221 a3d6298297 Merge f-pr4366 2026-10-29 15:10:59 +01:00
Niko1221 b82d1d4567 Merge f-028-tools-p1 2026-10-29 15:10:59 +01:00
Niko1221 3b8dc8fa35 Merge f-028-app-p1 2026-10-29 15:10:59 +01:00
Niko1221 d76b50681a Merge f-028-nerazim: DeepSeek stop ids (test_dsv41 stop-id assertion follows the fix; the arch_from_tokenizer cherry-pick is eng-p1's) 2026-10-29 15:10:55 +01:00
Niko1221 77287f13ec Merge f-028-eng-p1: engine P1 fixes 2026-10-29 15:10:41 +01:00
Niko1221 95563ff292 Merge f-028-defaults2: route prior off by default, tool_call_recovery on 2026-10-29 15:10:41 +01:00
Niko1221 5c8bcf4307 docs: windows of 16 rows and the four-card AMD profile (BATCHING.md)
Which builds take 16 rows and why, how the 8-row build stays main's code, what was tested (real 16-row windows,
identity on seven boxes), the four-card profile with its measured effect per setting, and the opt-out.
2026-10-29 15:06:10 +01:00
Niko1221 4fdb563a34 Four-card AMD serving profile: 32 slots in 4 groups, split 13,26,39, chunk 2048 (setup and launcher)
For exactly four Radeon AI PRO R9700 cards (at least 28 GB each, hip, no --peer-device) running Qwen3.8-Flash-Next
IQ3_S, setup.py and the launcher fill in what the person has not chosen: parallel 32 with --batch-groups 4, the layer
split 13,26,39 (the output head costs about four layers on the last card), --prefill 2048 (in place of the "auto" setup
writes), and STRATA_QSA_SLOTS, STRATA_BATCH_SELF_COMMIT and STRATA_SLOT_COPY_DIRECT in the run config's env. A setting
of the person's own always wins; STRATA_FOUR_CARD_PROFILE=0 or "four_card_profile": false turns it off. The prompt cache
stays on (agent follow-up turns wait 0.8 s for the first token instead of 1.5 s).

Through the launcher's configuration on the 4 x R9700 box (total tok/s, warm round, parallel 4 before): 1 request 66.6 ->
67.9, 8 at once 156.6 -> 261.2, 32 at once 155.4 -> 369.6, four 7.9K prompts out 24.3 -> 35.0 and in 1892 -> 2706.
batch_test with the profile's flags: 32 of 32 slots identical to solo; a 25 minute soak of 32 clients: 0 errors.

serve/server.py no longer says "at most 8 requests" for a layer split (each pipelined group holds its own slots).
Tests: tests/terminal/test_amd_four_card.py.
2026-10-29 15:06:10 +01:00
Niko1221 9d3c8c3664 Verify and batch windows of up to 16 rows: HIP builds default to 16, narrow builds unchanged
A pipelined group of a layer split is its own window, so 32 slots on four cards were four groups of 8 and nothing
larger could run. The window limit is now a build option (STRATA_WINDOW_MAX_T = auto | 8 | 16; auto is 16 for HIP,
8 for CUDA; SYCL and Metal never set it): include/strata/kernels/window_limits.hpp.

A window of up to 8 rows runs exactly the kernels it ran before (HIP gfx1201 window 8 against main: 3096 of 3097
kernels identical, the other a debug variant; CUDA sm_86 and sm_60: identical or renamed but for a few prefill kernels
NVVM compiles differently, with identical results). The code for 9 to 16 rows is only reached by a wider window:
- per-row arrays of the verifier, the expert source tables (128 / 256 entries) and the remote / peer expert tables follow
  the window; CPU expert jobs stay at 8 tokens (a 9..16-row group is two jobs over the same expert);
- the hyper-connection read is made in slices of 8 rows (its Q8_0 launch would otherwise skip rows);
- the grouped expert kernels run a second pass (SKIP template argument) instead of a 16-entry shared array (51 KB,
  over the 48 KB static limit of CUDA);
- resident_plan runs on 128 threads (up to 8 rows, as it was) or 256; MMVQ, bf16 GEMV, shared expert and MoE combine
  take 9..16 columns only in a wide build; the sm_60 packed path runs wide calls in passes of 8;
- STRATA_P4_TIMING and STRATA_P4_TRACE (include/strata/program/pipe_probe.hpp): opt-in timing and trace of the
  pipelined windows, off unless set.

Tests: src/core/window_limits_test.cpp (target window_limits_test). Real 16-row windows (--batch 64 on four stages,
--batch 16 --batch-groups 1) decode the tokens they decode alone.
2026-10-29 15:06:02 +01:00
Niko1221 061c542010 Metal DeepSeek: stop leaking Metal buffers and command buffers (16 -> 3 tok/s after a few requests)
The engine's host threads have no autorelease pool, and Metal parks one extra
reference per new MTLBuffer, command buffer and encoder in the calling thread's
pool.  Nothing drained it, so every buffer Context::alloc made (a 16 KB job
array per expert matvec, ~90K a request) and every stream command buffer and
encoder stayed alive after free(): +1.3 GB, +90K Metal regions and a slower
queue per request, until a big Mac slowed to a crawl.

- Context::alloc and the fallback wrap: create the buffer inside an
  autoreleasepool (DS4 runtime only; the Qwen build is untouched).
- strata::gpu runtime: a pool around command-buffer, compute-encoder and blit
  creation; the compute encoder is retained and released in end_encoder.
- Event marks: an event recorded again, or destroyed, before it was waited on
  now drops its command buffer token (ds4_ctx_drop / Context::drop_mark).
- route, combine and sparse_attn free the stand-in buffers they allocate for
  absent inputs.
2026-10-29 13:16:33 +01:00
Niko1221 f2bf7a3ce3 [Engine] SYCL: the lane-cell prompt attention default is for Battlemage (Xe2) cards only
On an Arc A750 the lane-cell score phase read a 4,106-token prompt 0.7% slower than the unchanged kernel (three
interleaved runs against the published 0.1.52 engine; with STRATA_ATTN_LANECELL=0 the branch matched it), while it is
+5% on the Arc Pro B65.  The default now follows strata::intel_gpu_gen(): the runtime's architecture enum first, the
device name only as a fallback.  STRATA_ATTN_LANECELL=1/0 still forces either way on any device.
2026-10-29 12:47:50 +01:00
Niko1221 6124c35e39 [Engine] SYCL: native_expert_bench reports microseconds, not milliseconds
Problem: the timing lambda measured milliseconds per iteration and returned them, while the printout labels the value
"us" and derives its GB/s as `bytes / us / 1e3` - so every number this bench has printed was 1000x too small in time
and 1000x too large in bandwidth.  A launch that moves 19.7 MB in ~140 us was reported as 0.1 us and 148,713 GB/s, which
is why the tool looked broken rather than mislabelled.  The wall clock always agreed with the real work (200 iterations
618 ms, 2,000 iterations 3,566 ms).

Result, 2x Arc Pro B65, shard 1 (Qwen3.8-Flash-Next IQ3_S experts, 10 groups), before and after the one-line fix:

| case | before | after |
| --- | ---: | ---: |
| 2 tokens | 0.1 "us", 148,713 "GB/s" | **143.4 us: gate/up 84.5 us (148 GB/s), down 59.4 us (78 GB/s)** |
| 8 tokens | 0.3 "us" | **340.8 us: gate/up 217.4 us (58 GB/s), down 126.0 us (37 GB/s)** |

and the model's own layers at two tokens: layer 30 iq3_xxs/q2_0 143.6 us, 31 iq3_xxs/iq4_nl 140.4 us, 32 iq2_s/iq4_nl
135.5 us, 33 iq3_xxs/iq4_nl 140.2 us - gate/up 131-149 GB/s, down 78-168 GB/s, results bitwise equal to the reference in
every case.

Approach: return microseconds (`ms * 1e3 / iters`), which is what the printout, the minimum-of-three logic and the
bandwidth formula already assume.  No kernel, no timing path and no measurement logic changed.

Validation: the same run before and after; the wall-clock scaling that exposed the bug; bitwise equality retained in
all four model layers and both token counts.  Not run: CUDA/HIP (this file is the port's own), other shards.

Limits: this fixes the units only.  The numbers are per-call costs with the bench's own synthetic groups, so a rate taken
from it still has to be matched to the real window (48 calls per layer step).
2026-10-29 12:45:36 +01:00
Niko1221 8475011ac1 [Engine] SYCL: native_expert_bench refuses to time a launch that wrote nothing
Problem: the bench fills its output with 0xff and then compares the mode against the reference, so a launch that
returns without enqueueing leaves both sides bitwise equal on an untouched buffer and the tool reports "0 differing"
and a 0.1 us timing - a measurement of nothing that looks like a very fast kernel.  The 0xff fill is NaN as a float, so
"no finite value in the reference window" is exactly that case.

Result, 2x Arc Pro B65, shard 1 layer 20 (`iq3_xxs/q2_0`, 10 groups, 2 tokens): the guard does *not* fire - the window
does write finite values, so this defect is not what makes the timing wrong.  The timing is still 0.1 us per launch
against a 19.7 MB working set (0.3 us at 8 tokens), which scales with work but is 20-60x too fast for the launch to be
waited for; the remaining cause is in the timing loop's wait, not in the launch.  That stays an open port item.

Approach: the guard only refuses and reports; it changes no timing path and no kernel.

Validation: builds and runs on the box; the run above is the only one taken.  Not run: CUDA/HIP (this file is the port's
own), and the timing itself is still unproven - do not use its numbers.

Limits: this is a diagnostic, not the fix.  `native_expert_bench` numbers remain unusable until the wait is found.
2026-10-29 12:45:36 +01:00
Niko1221 89253935db [Engine] SYCL: refuse a --mtp-q4 draft head when the Q6_K head is stored as planes
Problem: `--mtp-q4 head` builds the Q4_0 draft head by gathering the subset and dequantizing it row by row, but with
`STRATA_Q6_PLANAR` (the SYCL port's default for Q6_K heads) the source is stored as 16-byte-aligned planes and the
gather copies planes as if they were rows.  The result is a draft head that proposes nothing at all, with no error:
decode falls from 73.8-75.6 tok/s to 35.3 tok/s (the non-speculative rate) and the DONE line reports "0 of 0" drafts.

Result, 2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S, 128 tokens of a 483-token chat prompt, one warm request each:

| configuration | decode | drafts |
| --- | ---: | --- |
| `--mtp-q4 head`, planar head (before) | 35.3 tok/s | 0 of 0 |
| `--mtp-q4 head`, planar head (after) | refused with "needs the packed Q6_K head layout (STRATA_Q6_PLANAR=0)" | - |
| `--mtp-q4 head` with `STRATA_Q6_PLANAR=0` | 69.5 tok/s | 54 of 63 |
| default head (no `--mtp-q4`) | 74.9 / 75.6 tok/s | 55 of 63 |

Approach: a guard in `make_q4_head` that refuses the option when the head really is planar, so the engine says why
instead of running with speculation silently disabled.  The packed-layout path is unchanged and still works, which is
what the measurement above shows.

Validation: the guard message appears in the engine's own output; `--mtp-q4 head` with the packed layout still produces
54 of 63 drafts; the default path's tokens are identical to the pre-change reference (`compare_tokens.py`, no
difference) and its rate is unchanged.  Not run: CUDA/HIP (the file is the port's own copy and the guard is behind
`STRATA_NATIVE_EXPERTS`), a non-Q6_K head, `--batch-mtp`.

Limits: this makes a broken opt-in fail loudly; it does not make the 4-bit head fast.  With the packed layout it is
*slower* than the Q6_K head (69.5 against 74.9), so the option is still not worth enabling.
2026-10-29 12:45:36 +01:00
Niko1221 e318f71e75 [Engine] SYCL: copy the prompt path's grouping tables instead of reading them from mapped memory
Problem: the prompt path's per-layer grouping tables - `[ids | slot | src]`, T x K int32s each, ~245 KB per layer at a
2,048-token chunk - live in mapped pinned host memory, and on the mapped arm the GPU reads them there, once per layer.
On a PCIe-attached pair that is 48 rounds of host-memory reads per chunk, and the port already carried the A/B arm to
copy them instead (`STRATA_GROUP_COPY=1`) without a measurement deciding between them.

Result, 2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S, `--pipeline-windows 2`, three warm repetitions, same build, back to
back:

| case | mapped arm | copy arm | gain |
| --- | ---: | ---: | ---: |
| prefill 4,106 tokens (AOT) | 1,303.2-1,303.5 tok/s | **1,332.8-1,337.5** | +2.5% |
| prefill 16,380 tokens (AOT) | 1,617.8-1,622.7 | **1,657.2-1,665.2** | +2.5% |
| prefill 10,938 chat prompt (AOT) | 1,575.6-1,578.4 | **1,613.8-1,616.8** | +2.5% |
| decode, chat prompt, 128 tokens | 74.9 / 75.6 | 74.9 / 75.6 | unchanged |

Approach: the default flips to the copy arm for this port; `STRATA_GROUP_COPY=0` restores the mapped one.  Both arms
allocate nothing new when the buffer is already big enough, and the copy arm reuses the host vectors the code already
keeps for exactly this.

Validation: the token stream is identical between the two arms at both prompt sizes and on the decode run
(`scripts/compare_tokens.py`: first difference none), and identical to the pre-change reference (the rc-p16k run of
round 42).  Not run: CUDA/HIP (the file is the port's own copy), a host-shared-memory device (an iGPU, where the mapped
arm may be the better one - hence the switch), a 32K prompt.

Limits: +2.5% on prefill only; the decode path does not use these tables.
2026-10-29 12:45:36 +01:00
Niko1221 bf8d88b9dd [Engine] SYCL: a pipelined window never self-commits, so --pipeline-windows 2 is token-exact
Problem: `--pipeline-windows 2` (the two-card window overlap) ran but produced different tokens from
`--pipeline-windows 1`.  Ten rounds of switches, splits and modes narrowed it to the two alternating verifiers, and a
state dump (STRATA_PIPELINE_DUMP: a hash of the first stage's GDN recurrence and conv history after every window)
found the moment: identical after the prompt, different after the first decode window.

Root cause: the serial path lets a one-token window advance the GDN state inside the window and skips the commit
graph for it (`record_window` + `commit`).  That is safe there because a serial window never runs before its
predecessor's verdict.  A *pipelined* window may: stage 0 runs window K+1 speculatively while window K's verdict is
still open, so a self-commit lands on state that is not final yet - and the first decode window of every request is a
single token.

Fix: a per-verifier switch (`set_self_commit_off`) that the pipelined verifiers set.  With it `record_window` does not
self-commit and `pl_commit_async` always queues the commit graph - the configuration that already measured
token-identical (`STRATA_ONE_TOKEN_COMMIT=0`) but kept a global switch.

Result on 2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S, same build, back to back:

| case | pw 1 | pw 2 | tokens |
| --- | ---: | ---: | --- |
| short prompt, 256 tokens, spec 4 | 56.3 / 56.1 tok/s | **59.6 / 61.4** | identical |
| 4K context, 128 tokens | 55.6 | **60.3** | identical |
| `--spec 2 --spec-min-p 0`, 64 tokens | 49.2 / 50.3 | **59.6 / 63.2** | identical |
| 512 tokens, one run | - | **60.6** | - |
| prefill 4,106 tokens | 1,244.8 / 1,242.0 | 1,253.8 / 1,251.3 | identical |
| prefill 16,380 tokens | 1,548.1 | 1,561.1 | - |

So decode gains 7-9% at the default window and 21-26% at two-token windows, and the prompt path is unchanged or
marginally faster.

Validation: tokens identical in all four decode cases above (`scripts/compare_tokens.py`); the serial path's tokens are
unchanged against the pre-change reference.  The state dump still reads one window behind in the pipelined arm (an
ordering artifact of where it copies), so the token comparisons are the evidence, not the dump.
Not run: CUDA/HIP (the files are the port's own), a 32K-context prompt, an overnight soak.

Limits: `--pipeline-windows 2` stays opt-in.  It costs 278 MiB of card 0's expert cache (the second verifier and the
GDN snapshots) and its gain depends on the window size, so the default remains `--pipeline-windows 1`.
2026-10-29 12:45:35 +01:00
Niko1221 7356aa1404 [Engine] SYCL: print the pipelined commit's count when a state dump is requested
The pipelined state dump showed the first multi-token window leaving the GDN state one row ahead of the serial path,
so the next question was whether the commit count was wrong.  With STRATA_PIPELINE_DUMP set, `pl_commit_async` now
prints `n_keep`, `T`, `pos0` and whether the one-token skip applies, which answers it: the counts are right.

```
commit n_keep=1 T=1 pos0=18 self_skip=1      (stage 0)
commit n_keep=1 T=1 pos0=19 self_skip=1
verdict p=19 T=1 a=0 B none
commit n_keep=1 T=1 pos0=19 self_skip=1      (stage 1)
commit n_keep=2 T=2 pos0=20 self_skip=0      (stage 0)
commit n_keep=2 T=2 pos0=20 self_skip=0      (stage 1)
verdict p=20 T=2 a=1 B launched ON
```

So the multi-token window commits two rows in both stages, exactly as the verdict says, and the extra row must come
from the *window's own* self-commit: `record_window` self-commits when `T == 1 && !batch_rec_ && !g_qfuse() &&
one_token_self_commit()` (verify.cpp:844), and the transition from a self-committed single-token window to the first
graph-committed window is where the state gains the row.  That is the next thing to diff - the dump currently hashes
the recurrence and the conv history together.

Debug only: the print needs a state dump to be requested and changes no behaviour.
2026-10-29 12:45:35 +01:00
Niko1221 11d337537c [Engine] SYCL: the pipelined commit skips the graph for a one-token window
Problem: the serial commit knows that a one-token window advances the GDN state itself (`record_window`) and skips the
commit graph for it; `pl_commit_async`, which the pipelined windows and the pipelined prompt reader use, ran the graph
anyway.  A one-token window therefore advanced the state twice.  The first decode window of a request is always one
token, which is why `--pipeline-windows 2` produced different tokens from `--pipeline-windows 1`.

Evidence: the state dump added in the previous commit (STRATA_PIPELINE_DUMP, hash of the first stage's GDN state after
every window) was identical for both arms after the prompt (`pos 18 sum 1952.13774 hash b97305876cf73d2c`) and
different after the first decode window (`pos 19` 3222.063 in the pipelined arm against 2815.93065 serial).  With
`STRATA_ONE_TOKEN_COMMIT=0` - which makes the serial path take the graph as well - the two arms produced **identical
tokens**, which pinned the asymmetry to this one branch.

Result: with the skip, the two paths' states agree after the first and second windows (`pos 19` and `pos 20` hashes
equal) and the tokens are identical up to and including token 5; the first difference moved from token 4 to token 6.

Validation: `--pipeline-windows 1` tokens are unchanged (identical to the reference), and the prompt path, which also
commits through `pl_commit_async`, did not move (4,106 tokens 1,239.7-1,248.9 tok/s, 16,380 tokens 1,552.1, the same
as before the change).  Not run: CUDA/HIP (the file is the port's own copy), a 32K-context prompt.

Limits: this does not make `--pipeline-windows 2` token-exact.  A second defect remains: after the first *multi-token*
window the pipelined state is one row ahead of the serial one (`pos 22` in the pipelined dump equals the serial
`pos 23`), so the commit count and the window's own advance still disagree somewhere for T > 1.  The path stays
opt-in and the shipped default is `--pipeline-windows 1`.
2026-10-29 12:45:35 +01:00
Niko1221 478964ecaa [Engine] SYCL: a state dump for the pipelined windows, and the two defects it already shows
`--pipeline-windows 2` runs, is faster (57.7 tok/s against 51.3; 68.1 against 45.9 with a forced window size) and
produces different tokens.  Rounds 16-21 eliminated every switch and mode: the speculation (THETA), the prestaging,
the agreement chain, the snapshot overlap, a fixed window size, the split point (a one-layer stage 1 still differs)
and the elastic KV (off by default).  What is left is the two alternating verifiers themselves.

This adds the instrument for the next step: STRATA_PIPELINE_DUMP=<file> appends a hash and the sum of the first
stage's GDN state (recurrence state plus conv history) after every window.  It is debug-only - it waits for the
stage's stream - and off unless the variable is set.

It already shows one defect on its own: in the pipelined path the same position of a second request does not hold the
same state as the first request's (`pos 18 windows 2 sum 3669.18189 hash 5f718405585185d9` against
`pos 18 windows 1 sum 1952.13774 hash b97305876cf73d2c`), so a request does not start from a reset sequence state.
That is separate from the token divergence, which reproduces on the very first request of a fresh process
(`compare_tokens.py`: first difference at token 4).

The serial path needs the same dump before the two can be diffed element by element; that is the next step, and
`--pipeline-windows 1` (the shipped path) is unaffected by this commit.
2026-10-29 12:45:35 +01:00
Niko1221 09524eb479 [Engine] SYCL: the prompt attention's query batch is 128 by default
Problem: `attn_batch` was 32, a value chosen for the shuffle-reduction score phase's vector-matrix kernel.  The
lane-cell phase is now the prompt default, and it wants more queries per launch.

Result, same build, back to back, 3 warm repetitions each (2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S, 2,048-token
chunks):

| prompt | batch 32 | batch 128 | gain |
| ---: | ---: | ---: | ---: |
| 4,106 tokens | 1,227.9-1,235.1 tok/s | 1,240.9-1,247.6 | +0.9% |
| 16,380 tokens | 1,582.2-1,584.2 | 1,604.9-1,605.8 | +1.3% |

Batch 512 is another 0.2% on the 16K prompt but takes four times the scratch (400 MB per stage instead of 100 MB),
so 128 is the trade.  The expert cache keeps its slots: 10,253 either way at 4K.

Approach: the default of the `STRATA_ATTN_BATCH` switch (added earlier this branch) moves from 32 to 128; the
variable still overrides it in 1..1024.

Validation: 64 generated tokens are identical to the serial reference (`scripts/compare_tokens.py`, first
difference: none).  Not run: CUDA/HIP (untouched - the switch is the SYCL port's), a card small enough for the
100 MB of extra scratch to cost expert-cache slots.

Limits: the gain is attention-bound and small; it is the tuning of the phase that landed in the previous commit, not
a new path.
2026-10-29 12:45:35 +01:00
Niko1221 949f77c0a8 [Engine] SYCL: prompt attention takes the lane-cell score phase, +5% on a prompt read
Problem: the prompt path runs `qsa_decode_attn_batch` (the tensor-core kernel is opt-in and 2.9x slower here), and
attention is 21% of a 4,106-token prompt's GPU timeline and 24% of a 16,380-token one.  The port already had the
`STRATA_ATTN_LANECELL` score phase - one cell per thread, no shuffle reductions, scores bit-identical to the
shuffle reduction - but only behind an environment variable, so nobody got it.

Result, same build, same box, back to back, 3 warm repetitions each (2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S,
`--spec 4`, 2,048-token chunks, nothing else on the GPUs):

| prompt | shuffle reduction | lane-cell | gain |
| ---: | ---: | ---: | ---: |
| 4,106 tokens | 1,167.4-1,171.9 tok/s | 1,226.3-1,235.3 | +5.2% |
| 16,380 tokens | 1,456.3-1,457.4 tok/s | 1,524.3-1,526.2 | +4.7% |
| decode (256 tokens, 18-token prompt) | 56.5 / 57.0 | 56.6 / 57.0 | unchanged |

Approach: the choice follows the query count.  The prompt path hands the kernel a batch of queries (`attn_batch`, 32
by default), the decode window hands it the window's few rows (at most eight per call), and the few-query case is
1.4% slower with the lane-cell phase (55.9-56.3 against 56.6-57.0 tok/s), so `n_q >= 16` takes it.
`STRATA_ATTN_LANECELL=1/0` forces either way, so the old path is one variable away.

Validation: 64 generated tokens from the gated build are identical to the same prompt's tokens from the serial
reference (`scripts/compare_tokens.py`, first difference: none); the lane-cell phase's scores are the shuffle
reduction's bits by construction (one cell per thread, same order).  Not run: CUDA/HIP (untouched - the change is in
`sycl/src/kernels/cuda/qsa_decode_attn.dp.cpp`, the port's own copy), the parity bench on the gated build (it forces
its own arms), a 32K-context prompt.

Limits: measured on two Arc Pro B65 with a fully resident expert cache; the gain is attention-bound, so a prompt
short enough to be dominated by the expert GEMMs will show less.
2026-10-29 12:45:35 +01:00
Niko1221 7e0f719903 [Engine] SYCL: a layer-split stage must be a GPU of the primary device's backend
`--batch N` (the concurrent serving slots) loads the OpenCL adapter as well, so on a two-card `xe` box
dpct::device_count() then reports five devices: the two Level-Zero GPUs, the CPU, and the OpenCL view of the same
two cards.  An automatic split took every index above 0, so it tried to put a stage on the CPU and the engine died
before serving:

    layer split across 5 GPUs: CUDA0, then CUDA1,2,3,4 (split auto)
    layer split, CUDA2 weights: the weight arena does not fit (1466 MiB needed, 0 MiB of 160876 MiB free on that card)

The auto-filled split now only takes GPUs of the primary device's backend, so `--batch 2` with `--layer-split auto`
starts on the same 25/23 split as a plain run.  An explicit `--layer-split K` / `--split-device` is unchanged.

Measured on 2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S, 64 new tokens per slot:

| slots | window | rows/window | aggregate |
| ---: | ---: | ---: | ---: |
| 1 (speculative stream) | 44.4 ms | 2.35 | 57 tok/s |
| 2 | 40.6 ms | 2.00 | 49 rows/s |
| 4 | 58.1 ms | 4.00 | 69 rows/s |
| 8 | 88.7-89.3 ms | 7.00 | 78-79 rows/s |

So a batch window costs about 18 ms + 10 ms per row, and eight slots (the most that fit the VRAM) reach ~79
tokens/s of aggregate decode - above the single stream's 57, below the 100 the two-card budget would need.
2026-10-29 12:45:35 +01:00
Niko1221 f7570e23e2 [Engine] SYCL: make the prompt attention's query batch a switch, and measure it
The prompt path runs the decode-shaped FP32 attention kernel `attn_batch` queries per launch (the tensor-core
kernel is opt-in and 2.9x slower here, see qsa_prompt_attn_parity).  32 was chosen for a vector-matrix kernel;
on 2x Arc Pro B65 more queries per launch give it more to overlap.

Measured on Qwen3.8-Flash-Next IQ3_S, 16,380-token prompt, second request in the process (STRATA_PREFILL_TIMING):

| attn_batch | qsa attn | prefill |
| ---: | ---: | ---: |
| 32 (default) | 2,572 ms (23.0%) | 1,294 tok/s |
| 64 | 2,492 ms | 1,309 |
| 128 | 2,448 ms | 1,316 |
| 256 | 2,422 ms | 1,315 |
| 512 | 2,409 ms (22.0%) | 1,319 |

So the switch is worth ~2% and the default stays 32 (the difference is inside the run-to-run spread on one pair of
runs); STRATA_ATTN_BATCH=512 is the measured best for a long prompt.  The kernel itself is the problem: it is
1-4% of the card's fp16 peak at long context, and the XMX port is correct (2e-6 against FP64 in
qsa_prompt_attn_parity) but 3x slower, using 122,880 of 131,072 bytes of local memory for its 64-cell layout
(mode 32 halves that to 86,784 and is slower still).
2026-10-29 12:45:34 +01:00
Niko1221 d56565ce39 [Engine] SYCL: the grouped-expert bench reports a device-side launch failure instead of silently timing nothing
The bench's queue is created with an exception handler, which swallows a failing kernel: the timing then reads
"0.3 us per group of 10 experts" because nothing ran, and the bit-for-bit comparison passes trivially (both
buffers stay at the 0xff fill).  Every wait now goes through wait_and_throw() and prints the what() of a failure.

Verified on the Arc box: a run with real geometry prints dn=10 and non-zero outputs, so the kernel does run; the
per-mode timing of a single small group is still under one expert's memory traffic and needs the mode/phase
semantics checked before its GB/s numbers are quoted.
2026-10-29 12:45:34 +01:00
Niko1221 da3df90eb8 [Engine] SYCL: build the grouped-expert bench, and port it to SYCL
sycl/src/kernels/native_expert_bench.cpp was in the tree without a target, so it never compiled - and it still
carried raw CUDA calls (cudaMalloc, cudaMemcpy, cudaMemsetAsync, cudaFree, cudaEventRecord/ElapsedTime). It is now
a target under STRATA_NATIVE_EXPERTS and uses USM allocations, queue copies and a host clock on its own in-order
queue, so the grouped native expert modes can be timed and compared bit for bit on real GGUF rows at the decode
window's shape (G experts x T tokens).

Run: native_expert_bench <shard1.gguf> <layer> <G> <T> <ref mode> <mode> [iters]

Measured on the Arc box (layer 0 of the IQ3_S pack = iq3_xxs gate/up, iq4_nl down): every mode is bitwise equal to
the reference, G=480 x T=6 reports gu 7.7 us and down 5.3 us for the whole group - the absolute numbers still need
the mode's own semantics checked (a single group's 0.3 us is below one expert's memory traffic, so that path is
returning early).
2026-10-29 12:45:34 +01:00
Niko1221 16ccaf6150 [Engine] SYCL: the verifier's stage profile reads its stamps on its own stream
STRATA_VERIFY_PROFILE collects GPU timestamps per layer step into device memory, but the readback ran on the
device's default queue: neither ordered after the window's work on cs_ nor necessarily this stage's device. That
is why the stage table was only ever printed in the eager mode, which stamps from the host after draining the
device - so its numbers include launch overhead and gaps and cannot attribute kernel time.

* collect_profile() reads the stamps on cs_ (under OnDevice(device_)) and waits there.
* The window prints the table from the GPU's own clock every N windows:
  STRATA_VERIFY_PROFILE=1 STRATA_VERIFY_PROFILE_EVERY=32, without the eager drain.

Measured on 2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S, decode window T=4, per stage (24 layers) and per window:
total 20-24 ms; of that the routed experts ("VRAM hits") 5.0-6.6 ms in the GDN layers and 1.3-3.0 ms in the QSA
layers, the dense q8/qkv gemv 2.0, hc-read1+router 1.9, shared+quant 0.96, out-proj 1.1, z 0.97. The eager table
used to overstate hc-read0 and the experts by 2-4x because it timed the host's waits.
2026-10-29 12:45:34 +01:00
Niko1221 11a72289d2 [Engine] SYCL: --pipeline-windows runs again (three DPCT migration defects)
The two-card window overlap never ran on the Intel engine: the first poll of
every pipelined window returned "verify: error".

* Verifier::done() lost CUDA's cudaErrorNotReady branch. The port's
  sycl_event_query() returns 1 for "still running"; the migrated code treated
  every non-zero value as an error. A pending window is not an error now:
  flush the queue every 2 ms and time out after 20 s, as the CUDA original does.
* A drafter completion event was used as a barrier on the other card's queue.
  SYCL rejects a cross-context dependency ("Cannot submit to a queue with a
  dependency from a graph that is associated with a different context"). On two
  cards the host waits for the event; the same card keeps the barrier.
* The first card's second verifier (ver_b) took whatever device was current at
  init; it is created under OnDevice(0) now.

Diagnostics: queue_wait_diag() names the queue, its device and the SYCL message
instead of "error"; an unqueryable completion event says so; and with
STRATA_VERIFY_DEBUG=1 every verifier prints its device and streams.

Verified on 2x Arc Pro B65, Qwen3.8-Flash-Next IQ3_S:
* --pipeline-windows 1: tokens identical to the serial path (64 tokens on a
  short prompt, 32 after a 4K prompt, two runs each); a short prompt read drops
  from 206 ms to 118 ms (-43%).
* --pipeline-windows 2: runs now (it crashed before), ~8-20% faster decode, but
  it does NOT produce the serial path's tokens yet - the decode overlap and its
  GDN rollback still need work on this backend. Not a default.
2026-10-29 12:45:34 +01:00
Niko1221 43f32b3046 Server: only a one-token end marker is a stop id; pieces of <|im_end|> no longer end a DeepSeek reply ("Nerazim" came out as "Neraz", #4576)
Service took the ids of "<|im_end|>" and "<|endoftext|>" as its stop ids whatever the tokenizer made of them. DeepSeek-V4.1's tokenizer
has neither as one token: it cuts them into '<', '|', 'im', '_end', '|', '>' and 'end', 'of', 'text'. A server run without the family
named (a hand-written config, a gateway) therefore ended the reply in front of the first "im", "|", ">", "end", "of" or "text" it
wrote, and the real end token (id 1) did not end it. Nerazim is N, er, az, im: the reply stopped at 'im' and the client got "Neraz".
The engine and its --eos 1 were fine; nothing was cut by the stop-string hold-back (StopMatcher gives its held tail back at the end).

end_token_ids() keeps a marker only where it is a single token, and falls back to the tokenizer's own end token (special_ids "eos").
Qwen's tokenizers are unchanged. The family guess from the previous commit still names the right parser and DSML for DeepSeek.

serve/test_stop_holdback.py: the end ids of both tokenizers; StopMatcher over every split of the text and one character at a time
(Qwen and DeepSeek stop lists, words ending like the start of a stop string); the Detokenizer plus StopMatcher byte by byte; and the
OpenAI and /v1/messages routes, streamed and whole, with and without the family named: no stop string (whole reply), a stop string that
never comes (the held tail is given back), a stop string (cut exactly there).
2026-10-29 10:26:56 +01:00
Niko1221 3fcb2106d0 serve: tool_call_recovery is on by default; "tool_call_recovery": false is the off switch
The tool-call forms the model drifts into (<parameter=NAME> as the opener, JSON inside <tool_call>, a bare <function=NAME>, a second call in one
wrapper) are read as the call when the tool is declared; anything else stays text. On the 1,462-turn drift corpus 98.3% of turns read as intended
with it and 92.9% without; the specimens of the tool corpus read the same with it on or off.

- Service.tool_call_recovery starts True; tool_call_recovery_setting(cfg) is the config rule (absent = on, a non-bool still stops the start).
- Tests: a fresh Service reads the drifted call without being told; the config rule; the old on/off switch test is unchanged.
- docs/DETAILS.md: the config row says on since 0.2.8.
2026-10-29 10:25:07 +01:00
Niko1221 20a63cf0f0 Residency-biased routing (STRATA_ROUTE_PRIOR) is off by default again on 12 GB NVIDIA cards (#4402)
0.2.7 turned STRATA_ROUTE_PRIOR=1.5 on by itself on a discrete NVIDIA card of 13 GiB or less. Reports and our own 0.2.7 agent check (4.5 of 10
tasks with it on, 5.6 with it off) tie it to repetition loops and weaker results in long agent sessions, so the owner took the default back
(2026-10-29). With nothing set the engine now routes exactly as 0.2.6 and as STRATA_ROUTE_PRIOR=0 did; STRATA_DEFAULTS=026 is unchanged.

- sm120_series.hpp: route_prior_default() answers 0 on every card; route_prior_resolve() is the pure rule the engine reads (a set value wins on every
  card); kRoutePriorSmallCard stays the 1.5 opt-in value.
- verify.cpp: g_route_prior() uses it; the "on by default on this card" start line is gone.
- Expert settings / Terminal: "Faster answers on 12 GB NVIDIA cards" is now an Off-by-default row that writes STRATA_ROUTE_PRIOR=1.5; the words
  say it is faster on 12 GB cards but can repeat itself in long agent sessions (nine languages). A 0.2.7 file with STRATA_ROUTE_PRIOR=0 reads as Off.
- Tests: arch_defaults_test (default table over 12 cards x hip x integrated x 026, explicit opt-in values), speed-switches.test.mjs and
  tests/terminal/test_speed_switches.py.
- docs/DETAILS.md: both places that described the 0.2.7 default.
2026-10-29 10:25:07 +01:00
Niko1221 72a2b77057 Server: a config that names no "arch" but ships DeepSeek's tokenizer is served as deepseek41 (#4137)
serve/server.py run on its own with a hand-written config (no "arch": "deepseek41") gave DeepSeek-V4.1 the Qwen defaults: the stop ids
of <|im_end|> and <|endoftext|>. The DeepSeek tokenizer splits those strings into pieces, so a "|" or ">" in a reply ended it
(finish_reason stop at token 19 in front of a Markdown table, or right after "## 3. Recom"), and the real end token (id 1) was not a
stop id, so it came out as the text <|end▁of▁sentence|>. Reproduced on 2 x RTX A6000 with the reporter's request: the config with the
arch answers 730-1161 tokens six times out of six; without it, stops at 192-436 tokens, one of them right after "## 3. Recom".

The tokenizer's own pre-tokenizer name ("deepseek3") now picks the family when the config names none; a named arch is never changed.

(cherry picked from commit b18292d702)
2026-10-29 10:21:33 +01:00
Niko1221 cfa197e21a Qwen: the automatic expert cache leaves the reserve it was asked to keep - the ~245 MiB the load allocates after it are priced (#4474)
--expert-cache auto sized the cache from the VRAM free after the weights, less --vram-reserve-mib, the draft head and the other priced
buffers. What the load allocates after the cache and no setting prices - the CUDA libraries the prompt path and the verifier warm up
(cuBLAS handles and workspaces, lazily loaded modules) and the verifier's small buffers - came out of the reserve, so the card ended
about 240 MiB under it: 459 MiB free at --vram-reserve-mib 700 on an RTX A4000 16 GB, 457 MiB on an RTX A6000 48 GB, under the 512 MiB line
the serve check warns about at every request (#2065). Measured with STRATA_TRACE=1: -164 MiB at the prompt path and -80 MiB at the verifier
beyond the draft head, the same with --prefill 512, 2048 and auto, on both cards, and the same on v0.2.6 (so not new in 0.2.7).

auto_cache_slots() (include/strata/core/auto_cache.hpp, tested in src/core/auto_cache_test.cpp) now leaves kLateAllocMib = 256 for it,
unless the card could not keep min_slots (#496) after it, and not under STRATA_DEFAULTS=026. An explicit --expert-cache N and
--vram-reserve-mib N are untouched; the cache is ~100 slots (about 1.8% on a 16 GB card) smaller.
2026-10-29 00:19:50 +01:00
WenyuanXu 22fb183cf6 DeepSeek engine: a layer split's chunked prompt no longer deadlocks - the host waits for another group's flag before it queues the device-side wait (2x H100, IST-DAS-2.25bpw)
On a two-GPU layer split, every prompt on the chunked path stalled: a device's wait kernel spun for a
hand-off while that device's thread blocked in its first cuBLAS call, and the other device's thread too.
host_wait_at_least polls the same host-mapped flag (the kernels' signed flag - seq test) before the four
cross-group waits of the chunk path, so the kernel finds the flag up. The GPU work and its order do not
change.
2026-10-29 00:05:59 +01:00
Niko1221 01d6f7b995 serve: an engine left behind by an earlier run is waited for until it has given its memory back (#4051)
The server ends an engine an earlier run lost (strata_control/procs.py) before it
starts the next one, and waited 10 s after the kill.  An engine with tens of GB
of pinned memory takes longer to give it back, so the new engine's memory guard
saw 50 GiB still in use and loaded a 40 GiB arena into 10 GiB.  The wait is now
up to 120 s, with a progress line every 10 s; one still there after that is said
in plain words (what it is, why starting now is risky, what to do) and the start
goes on: nothing is refused, the engine waits for RAM itself and warns.

Also documents what the parser repairs (#4078 #4427 #4077) and the arena RAM wait
in docs/DETAILS.md.  Tests: serve/test_leftover_engines.py and a fake-clock case
in tests/terminal/test_procs.py.
2026-10-29 00:01:54 +01:00
Niko1221 07d5ecfacb engine: wait up to 20 s for the RAM the expert arena needs, then load with a plainer warning (#4051)
An arena load with 10 GiB available for a 40 GiB arena sent a 60 GiB host into
memory reclaim until it was reset.  The check that is there warns only (a
cgroup limit below the arena still stops the start), and its own example is an
engine that is still exiting.  Before the warning, the load now waits for the
RAM to come back, re-reading it every 0.25 s for up to 20 s, and says so in the
log every 5 s (STRATA_ARENA_RAM_WAIT_S changes the seconds, 0 loads at once).
If it is still short the load goes on as before, with the numbers; below half
of the arena the warning says the whole PC can stall in reclaim and names a
systemd MemoryHigh= as the way to keep one program from taking all the RAM.
Nothing is refused or capped.

detail::wait_for_ram is the loop with its sampler and sleeper passed in, tested
in file_expert_source_test (test_wait_for_ram).  expert_source.cpp passes
g++ -std=c++20 -fsyntax-only against the CUDA 13.1 headers; the engine itself
was not built or run for this change.
2026-10-29 00:01:54 +01:00
Niko1221 b18292d702 Server: a config that names no "arch" but ships DeepSeek's tokenizer is served as deepseek41 (#4137)
serve/server.py run on its own with a hand-written config (no "arch": "deepseek41") gave DeepSeek-V4.1 the Qwen defaults: the stop ids
of <|im_end|> and <|endoftext|>. The DeepSeek tokenizer splits those strings into pieces, so a "|" or ">" in a reply ended it
(finish_reason stop at token 19 in front of a Markdown table, or right after "## 3. Recom"), and the real end token (id 1) was not a
stop id, so it came out as the text <|end▁of▁sentence|>. Reproduced on 2 x RTX A6000 with the reporter's request: the config with the
arch answers 730-1161 tokens six times out of six; without it, stops at 192-436 tokens, one of them right after "## 3. Recom".

The tokenizer's own pre-tokenizer name ("deepseek3") now picks the family when the config names none; a named arch is never changed.
2026-10-28 23:59:46 +01:00
Niko1221 d71132738e serve: damaged tool-call arguments, a stray closing think tag and a missing </function> no longer cost the call (#4427 #4077)
#4427: tool calls came back as text or with a wrong argument.
- A JSON array or object argument with a slip in it (a trailing comma, single
  quotes, a bare string between two members, brackets left open) is read as
  written instead of reaching the client as a string that fails its schema; the
  reporter's todo_write array with "pending", "in_progress" in one object is the
  test.  Valid JSON is untouched; text that is no array or object stays text.
  The repair is counted (/metrics strata:tool_args_repaired_total) and logged.
- A parameter's </parameter> followed by </tool_call> ends the value: the call
  whose </function> was left out is a call.
- The same holds for a call written inside the thinking, and one whose output
  ends on </function> inside the thinking; a following opener confirms an
  omitted </tool_call> there too, as in the answer.
- The call announced while streaming and finished by the end of the output
  carried empty arguments in its final event (read by MCP and the Responses
  assembler): it now carries the parsed ones.

#4077: after the reasoning ended at a closing think tag, one more at the start
of a line in the answer is the stray: the model quoted the tag while thinking.
It is dropped and the answer's code tracking starts over, so a call that follows
is not taken for a quote by a fence or backtick the reasoning left open.  The
reasoning after the stray is still sent as the answer (a stream cannot take it
back); the call arrives.  Logged and counted.

Adds serve/test_tool_corpus.py (49 specimens: the reporters' examples, the
Qwen3.8-Flash-Next form, the drift shapes, DeepSeek DSML; each whole, in pieces
of 1, 3 and 7 bytes and cut in two at every byte, streaming on and off) and
serve/test_tool_routes.py (Chat, Messages and Responses, whole and streamed).
2026-10-28 23:55:33 +01:00
Niko1221 84c7f43358 CUDA 12 driver floor: setup takes it from the engine package, not from a constant (#4247, #4188)
setup.py said the CUDA 12 engine 'works with driver 528 or newer' (525 on Linux), NVIDIA's minor-version line, while the signed feed's cuda12
packages (built with CUDA 12.9) carry min_driver 575.51 / 576.02 and strata update refuses them below that: a V100 PC on driver 556-560 was told
the engine would work and then that the feed had no engine for it.

strata_control/driver_floor.py reads the floor from the installed cuda12 package, else the signed feed (once, 10 s at most, only in a setup run
that needs the CUDA 12 engine), else the figure the published packages carry. Setup warns with that number (recommend, never force: a locally
built engine may still run); the only stop left is NVIDIA's own line, under which no CUDA 12 program starts. docs/OLDER_GPUS.md says the same.
2026-10-28 23:53:00 +01:00
Niko1221 012bea51f8 Packaged Terminal: setup.py without CMakeLists.txt, and strata install hands its options to setup (#4188)
source_version() read CMakeLists.txt, which the packaged Terminal does not carry: prebuilt_bases() raised FileNotFoundError and ended the setup.
It answers '0' now when there is no source tree, prebuilt_bases() then looks at the latest release only, and an installed engine is not called
stale for want of a version to compare with.

strata install passed its tail to setup.py through argparse.REMAINDER, which does not take a tail that starts with an option the parser does
not know (--source, --family): 'unrecognized arguments', or the value split from its option. The options of setup.py now go behind a --
before the parse; --update and --help stay strata's own.
2026-10-28 23:53:00 +01:00
Niko1221 5596cb2c5b launch: do not point at an engine log that was never written (#3714)
When the runtime refused the start because another host still owned the profile ('already has a live managed runtime'), no engine existed
and no log was written, but the error ended with 'The engine's log has the reason: <path>' and the screen showed an empty folder. A busy
profile now gets its own sentence (the other server, and strata launch --stop), and a log that is not on disk is no longer named, here or
in the sentence after an engine that stopped while loading.
2026-10-28 23:52:42 +01:00
Niko1221 587a474433 Qwen: the PLE residency policy gives its lock back when the lock is what leaves the automatic conversation cache too little RAM (#4518)
The 0.2.7 policy locks the 26.8 GiB PLE table in RAM at start when table + expert arena + 8 GiB fit. What the rest of the load takes
afterwards (KV pools, pinned staging, host buffers of two GPUs) is not known then: on a 128 GB two-GPU PC it left under 5 GiB and the
automatic conversation cache turned itself off, a regression against 0.2.6, which kept 8192 MiB.

When the cache is automatic and the lock came from the policy (not --ple-io ram), the cache is sized first: if unlocking the table
would give the cache more room, PleTable::unlock() hands the lock back (the pages stay cached, the OS may reclaim them), the log says
so, and the cache is sized from the RAM then free. --conversation-cache-mib N and an explicit --ple-io ram are untouched.
ple_lock_blocks_cache() is the decision, unit-tested in src/core/ple_residency_test.cpp.
2026-10-28 23:41:53 +01:00
Niko1221 35c0c9a8ae DeepSeek engine: slot_load() writes each tensor-parallel member's window ring and compressor group under its own device (#4350)
slot_load() selected the lead layer's device once and then copied into every member of a tensor-parallel group; the members' buffers
sit on their own GPUs, so on two identical cards (tp 2) the second member's copy ran with the wrong device current. A failed copy
makes slot_load() fail, and slot_take() then deletes the saved conversation. Every other per-member loop (restore, reset) already
selects the member's device; slot_load() now does too.
2026-10-28 23:41:53 +01:00
Niko1221 c7a0cef13a Conversation cache: parking an idle --batch slot no longer drops the main session's copy with the shared-prefix checkpoint (#4420)
A batch slot's chain holds one checkpoint (the one its admission copied in). park_slot() went through park_source(), whose
drop_superseded() (#342) removes a parked entry when the outgoing chain holds that entry's deepest checkpoint; the main session's
copy of the same conversation qualifies, and the root checkpoint at the end of an agent's system prompt went with it. The next
overlapping request then read the whole 5 K-token prefix again (323 ms in 0.2.6, 4130 ms in 0.2.7).

ConversationCache::supersedes_richer() says when the park would drop an entry that has a checkpoint the slot's chain lacks; park_slot()
then leaves the slot unparked (it is overwritten, as in 0.2.6) and says so. A slot with no such parked copy is parked as before.
The predicate and its test come from the report (shenmintao, #4420).
2026-10-28 23:41:44 +01:00
Niko1221 ea73eab442 DeepSeek engine: the auto RAM store no longer reads a discrete GPU's pinned buffers as unified memory (#4147)
store_gb_now() took any drop of 4 GB or more in the free RAM between the first estimate and the end of the weight upload for a
GPU that shares the PC's RAM, and subtracted a context-sized reserve (27 GB at 256K). On a discrete pair (2 x RTX 3090) the
pinned staging buffers and the page cache cause the same drop, so a 256K context shrank the store from 48 GB to 15-18 GB and
decode fell from 9-12 to 3-4 tokens a second (a regression since 0.2.4.2).

The decision is now store_gpu_shares_ram() (ds4_experts_rule.hpp): the RAM must have dropped AND a device must report
cudaDevAttrIntegrated. Strix Halo, the DGX Spark's GB10 and the Apple path keep the old rule; a discrete card never counts.
Unit test in tests/dsv41/experts_rule_test.cpp.
2026-10-28 23:41:26 +01:00
Niko1221 5ef90cb96b App host: DeepSeek launch arguments are built from what strata-dsv41 takes (#4358 #4359 #4505 #4507 #4504)
The Qwen engine's --vision stands alone, the DeepSeek engine's is a pair (--vision on|off). Taking out only the name left the value behind
(--eos 1 off --dspark auto: unknown option off, exit 2). A DeepSeek model keeps its pair whole now.

The Settings field 'GPU memory for other apps' wrote --vram-reserve-mib and the resource preference wrote --pool-workers into a DeepSeek
model's arguments; strata-dsv41 takes --reserve-mib and has no --pool-workers. Before a load, the arguments and the person's saved options
of a DeepSeek model are built from the engine's option list (src/dsv41/ds4_main.cpp, checked by a test): the reserve is renamed, anything
else is left out with its value and said in the load's log. The tune operation no longer writes the resource preference into a DeepSeek file.
2026-10-28 23:37:47 +01:00
Mike 3e1141edf0 GUI: ask for the longer wait only when all three of a Control's ceilings agree (#3459 #4191)
The app's ceiling probe read one file. The second symptom of this branch - `direct runtime intent has an invalid timeout`,
on a build whose CLI validator was already fixed - is the reason it now reads the operation record too: a start wait is
bounded by management, by the record written before the start runs, and by the runtime, and a Control can raise any of
the three on its own. The app asks for more than 600 s only when a Control's own sources hand both of the first two to
the runtime's ceiling, so a half-raised Control keeps the wait it will accept instead of failing the load. Anything the
app cannot read, or a line that still spells out 600, keeps today's 600 s.

Checked against the Control folders on this PC and against the released sources in git: v0.2.5 and v0.2.7 both carry
`MAX_START_WAIT = 1800` with both gates at 600, so the app asks them for 600 s - which is what they take; the patched
tree and the staged 0.1.52 component ask 1720 s for a 180 GB model; the installed 0.1.49.2 component asks 600 s. The
marker for the record is `"wait_s": (` rather than `wait_s`, because the record names that key in its field lists long
before its range table, and the first match there would have pinned the app to 600 s forever.

The packaged test drives the four Control shapes (nothing, runtime only, CLI only, all three) so each half-raised
combination is a case, not a guess.
2026-10-28 23:33:57 +01:00
Mike 293044e561 control: the operation record takes a long start wait too, and the tests walk the whole CLI path (#3459 #4191)
A local build of the previous commit got further and said `direct runtime intent has an invalid timeout`. A start wait is
checked three times before the engine is reached, and 27cbbfe6 raised only the runtime's: management.runtime_action (fixed in
the previous commit), journal._validate_direct_intent, and runtime.start itself. The record is written before the start runs,
so its own (0, 600) for runtime.start and (1, 600) for startup_wait_s stopped a long wait that the layer above had just
accepted.

The record now reads both ceilings from the runtime that runs the wait, the way the CLI validator does. A wait above them is
still refused, and stop and verify keep their own 600 s, which is what runtime.stop and runtime.verify take.

The tests are the reason this was found twice by hand instead of once here: the CLI test from the previous commit faked
runtime.start, so it stopped at the first gate and passed while the record still refused the wait. One test now walks
management -> journal -> runtime.start with only the engine itself faked, at 300, 600, 601 and MAX_START_WAIT, and says which
wait failed to arrive; another pins each kind's ceiling on the record directly. Reverting the journal change fails three of
them with the quoted message; with it, all four pass. StartWaitLimit keeps its records in a scratch folder.
2026-10-28 23:33:57 +01:00
Mike 5af539691d GUI: ask the installed Control for the start wait it actually takes, and let the CLI take the runtime's ceiling (#3459 #4191)
The version gate of the first commit was wrong, and a local build of it said so on the first load of a big model:
`Start wait must be finite seconds in 0..600`. Control validates a start wait twice and the two ceilings disagree.
`runtime.start` takes `MAX_START_WAIT` (1800, 27cbbfe6), but `strata runtime start --wait` - the command the app uses -
checks the wait in `management.runtime_action`, where 27cbbfe6 left it at 600. It refuses the start before the engine
is reached, so no released Control (v0.2.4 through v0.2.7, Control 0.1.45 to 0.1.52) takes a wait above 600 s however
it is numbered: a version gate could not tell which Control would take it.

`management.runtime_action` now takes the ceiling from the runtime it hands the wait to, for both start and restart,
which is what 27cbbfe6 had already done for the direct `runtime.restart` path. The default stays 300 s and every wait
at or below 600 s is unchanged. The app reads the ceiling out of the Control it found - the same source-read it does
for the settings fields and /v1/responses - rather than guessing from a version, so it keeps 600 s on the Control
people have today and takes the longer one as soon as that Control is updated. A Control it cannot read falls back to
600 s, which is what the app asks for now.

Tests: the CLI path gets the ceiling test that only existed for `runtime.start` before, and the packaged load test
drives the three Control shapes (no ceiling, runtime 1800 with a CLI stuck at 600, CLI following the runtime) instead
of a version string.
2026-10-28 23:33:57 +01:00
Mike 0e72aff563 GUI: the start wait follows the model's size, as strata launch does, and never asks an older Control for more than it takes (#3459 #4191)
The app waited a flat 300 s for any model, so a big model on a slow disk ended with `startup_wait_expired` while the engine
went on reading: #4191 reports it at about 180 GB and says editing 300 to 600 in this file fixed it on 0.2.1 but needs
several files now, and #3459 is the same stop on Windows after the tuning stage. Main solved the Terminal's side for
itself (27cbbfe6, #3949) and left this file with 300 s, or the config's engine_ready_s clamped to 600 s.

That clamp was written the same day as `MAX_START_WAIT = 1800`, on a branch whose Control still refused more than 600 s —
its message says "at most the 600 s Control allows". Control now takes 1800 s, so the clamp's reason has gone: it is the
only reason the app cannot use the size figure the Terminal already uses.

- startWaitFor(config,entry,model) takes the model's own size where the app has it (the installed model's bytes on disk,
  else the catalog's download figure) and applies main's shape: ten minutes, about eight more seconds per GB above 40 GB.
  A model nobody can size keeps the 300 s it always had, and a config's engine_ready_s is a floor, never shortened.
- The ceiling comes from the Control this app talks to, not from a guess: 600 s before Control 0.1.50, 1800 s from there.
  Control rejects a wait above its own ceiling before it starts anything, so an older Control keeps today's behaviour.
- Where the start now waits longer than the server's own engine wait (900 s), the run config gets `engine_ready_s` above
  it, as `strata launch` does: the server ends the engine when its wait runs out, so a start that waits longer than the
  server can never win. It is only ever raised, and only in the launch's own copy of the config.

No new variable, no change to Control or the Terminal, and nothing to set: the wait follows the model, as main chose for
the Terminal. The app's outer five-minute health loop is not the load wait in packaged mode (Control has already reported
ready), so it is left alone.

Validation: Linux, Node v22.23.1, against upstream/main 5e8a4cca on the same machine. `npm test` in apps/host: 1227 tests,
1120 pass, the same 2 failures as on upstream/main (component-updates "an older host left running" and timeskip-real
"Stop ends the job"). The two new tests cover 180 GB at 600 s on Control 0.1.45, 1720 s on 0.1.50 with the server readying
after it, a small model keeping 300 s, the 1800 s ceiling, and setup's 3600 s for DeepSeek being honoured and never
shortened. The 11 version and size cases were checked against the formula directly, including an unknown Control version,
which falls back to the conservative 600 s. Not run: a real load on a real PC, Windows, macOS.
2026-10-28 23:33:57 +01:00
Tsury b72f56e02a serve: only a whole quoted <function=NAME> opener keeps a parameter value open (#4078)
A bare `<function=` in prose or code (no name, or a name not followed by a
parameter or close) counted as a quoted call, so every later </parameter>
was skipped: the call came out as content, or never finished when streaming.
Adds the review's two values to test_quoted_call_values.
2026-10-28 23:33:10 +01:00
Tsury 77794e3400 serve: a string argument that quotes a tool call or reopens its parameter stays exact (#4078)
A parameter close inside a call that the value itself opened (more
<function= than </function> before it in the value), or one followed by the
same parameter name again, is part of the value. Prose between two calls
still ends the first call. Test: serve/test_quoted_call_values.py.
2026-10-28 23:33:05 +01:00
Günter b5aa1d94e0 Update test start: skip DeepSeek models when choosing the model to test with
discover_run_config picked the first installed model by id. With a DeepSeek and a Qwen model installed, "deepseek-..." sorted first, and the
test start ran the engine under test (strata.exe) with DeepSeek's options: "unknown argument: --eos", server_exited, engine 0.1.50 refused
("did not start correctly on this computer"). A DeepSeek model runs on strata-dsv41, which has its own package, so it is left out; with only a
DeepSeek model installed the update has no model to test with, as on a first run.
2026-10-28 23:31:38 +01:00
吴佳浩Alben 2f9c6fc35c Fix #4209: a saved preset's lower-case model tag reaches setup as its key
strata_cli/launch/settings.py saves the model into a preset/choices file lower-case ("--model iq3_xxs"),
and LaunchRunner.setup_args() forwarded it unchanged. setup.py's --model choices are the MODELS table's own
keys ("IQ3_XXS", "Gyro-S"), so argparse refused the argument: starting a saved preset with any choice that
differs from the installed config (--vision, --context, --kv) reran setup and stopped there. Typing the same
name on the command line worked, because cmd.py normalises it through Catalog.resolve().

setup_args() now resolves the pair through the same Catalog.resolve() and keeps the old value when it
cannot. The except is deliberately broad: the resolver reads the model table, and a table that cannot be
read here must not turn a launch that worked into a traceback - the values then go on to setup unchanged,
exactly as before. The new test drives that case with a resolver that raises.
2026-10-28 23:31:20 +01:00
Niko1221 c3326979fc docs/INTEL_ARC.md: the CPU and model-folder paragraph in plain words 2026-10-28 23:15:08 +01:00
Niko1221 7a80b3a96b Say plainly why a CPU without AVX-512 stops the engine: the pack folder without native_experts.txt, not the CPU, is the cause; doctor warns before the start, the launcher gives the next step, the SYCL engine names the folder like the CUDA one does 2026-10-28 15:37:12 +01:00
Niko1221 d52b73dc65 DS4_FAT_ROWS follow-up to the port: launches through strata_ds4_launch (the Mac build), off on Metal, only for the types f16_rows decodes (no trellis, no Q5_K / Q6_K down: a request would have stopped on 'not decoded'), at least 17 rows (the light kernels own the small experts) 2026-10-28 15:13:37 +01:00
Lyes SBAHI 0c5b157fbd DeepSeek engine opt-in: the busy experts of a prompt take FP16 GEMMs, not the tiles (DS4_FAT_ROWS=N, default 0: byte-identical path, the packed tiles run exactly as before). An expert of N rows or more has its weights decoded to FP16 once (iq::f16_rows, the values the tiles multiply), the rows gathered (gather_f16), cublasGemmEx, clamped SwiGLU in FP16 (swiglu_f16); SetArgs skip_ge leaves those experts out of the tiles. The tiles decode an expert's weights again for every 64 of its rows; a popular expert of a long chunk has hundreds. Measured on the RTX 5090 with the IST-DAS 2.25 bpw pack, v0.2.7 base, DS4_FAT_ROWS=64: a 13K-token prompt 1,400 -> 1,950 tok/s, a 5.4K prompt 1,065 -> 1,181, decode unchanged (28-33 short, 47-49 warm). 3x V100, Maya-XS: +6-8% on 8K and 32K prompts. The same products, the sums' order differs: greedy output can diverge after the first rounding (wording equivalent); KL vs the reference 0.276, the stock path 0.285 (the same band, measured on Maya-XS; not yet re-measured on this base). 2026-10-28 15:07:48 +01:00
Niko1221 46c152a131 Merge f-0271-macds: the Mac signing kit signs and notarises the DeepSeek engine folder (--engine) 2026-10-28 09:11:14 +01:00
Niko1221 78b99a6605 packaging/mac: sign-and-notarize.sh --engine signs and notarises the Mac DeepSeek engine folder (Developer ID, hardened runtime, manifest updated, signed folder tar) 2026-10-28 09:10:05 +01:00
Niko1221 55a557bb6d Merge f-0271-macds: the Mac reads DeepSeek's Engram rows with two threads (identical words run to run); DEEPSEEK_MAC.md numbers from that build 2026-10-28 06:20:11 +01:00
Niko1221 ed1e5546c2 metal: the decode-only Engram cap did not hold (reverted); DEEPSEEK_MAC.md and the plan's numbers from the two-thread build, with the prompt-speed cost 2026-10-28 06:19:41 +01:00
Niko1221 bc6c32555b metal: the Engram rows are read with two threads (more gave different words run to run; the cause is not found yet); a failed Engram row read is reported 2026-10-28 05:33:46 +01:00
Niko1221 a9686a900a Merge f-0271-macds docs (DEEPSEEK_MAC.md ranges and the measurement note) 2026-10-28 03:09:29 +01:00
Niko1221 b90c8a2172 docs/DGX_SPARK_LINUX.md: the not-tested line matches the measured four and eight chat slots 2026-10-28 03:09:29 +01:00
Niko1221 979639e9cd docs: DEEPSEEK_MAC.md says the speeds were measured beside another program and how the expert share is set 2026-10-28 01:04:00 +01:00
Niko1221 756c00fbf6 docs: DEEPSEEK_MAC.md ranges from the runs before and after the buffer-mapping fix 2026-10-28 01:03:23 +01:00
Niko1221 9258e4086e Merge f-0271-macds (DeepSeek on the Mac) into 0.2.7.1: setup.py keeps the Mac sentence and adds the DGX Spark one for Linux ARM64 2026-10-28 00:33:44 +01:00
Niko1221 09c18d3e98 Tests: the feed tests know that unknown platforms are skipped, and the install.sh release-path test gives a Linux ARM64 host its own feed file 2026-10-28 00:15:56 +01:00
Niko1221 58f244ab1e The feed parser skips what it does not know: an engine, DeepSeek engine, Control or app entry for an unknown platform, backend or archive format is left out with one plain line instead of refusing the whole feed 2026-10-28 00:15:56 +01:00
Niko1221 39c5534ed3 Linux ARM64 reads its own signed feed file (strata-feed-linux-aarch64.json): a Control up to 0.1.52 refuses a feed with a platform it does not know, so the aarch64 entries stay out of strata-feed.json 2026-10-28 00:15:56 +01:00
Niko1221 2a220c1d1c metal: a kernel's buffer binding never runs past the allocation that holds it (sparse_attn asked for one row more than the window buffer: a second GPU mapping of the same pages read stale rows; short prompts and follow-up turns answered differently on every run); the Mac engine build script always runs ninja 2026-10-28 00:11:30 +01:00
Niko1221 87a755bf1e README and install guide: the DGX Spark (Linux ARM64) row, pointing at the DGX Spark guide 2026-10-27 22:53:16 +01:00
Niko1221 3939193dd3 mac: the setup test question keeps the engine's own prompt window (a 16-token window gave a wrong first word on Metal) 2026-10-27 22:22:57 +01:00
Niko1221 864afc7421 mac: the DSpark message says it is not supported on a Mac yet; --dspark on warns before loading (engine and launch) 2026-10-27 21:58:12 +01:00
Niko1221 d50f6a77ed metal: the DSpark drafter stays off on a Mac unless asked for (its quantised Markov bias has no Metal kernel; the setup test stopped with kernel launch with no Metal dispatch) 2026-10-27 21:55:37 +01:00
Niko1221 5729a6e50a docs: DEEPSEEK_MAC.md with the numbers of the 0.2.7.1 build 2026-10-27 21:50:39 +01:00
Niko1221 48c8e85843 mac plan: measured numbers and expert share of the 0.2.7.1 build (68-69 GB on a 128 GB Mac) 2026-10-27 21:47:09 +01:00
Niko1221 4daa83e75f docs/DGX_SPARK_LINUX.md: the 61-task quality suite with the sm_121 defaults on and off 2026-10-27 21:34:05 +01:00
Niko1221 5cfe96ee6a docs: DeepSeek on the Mac in MAC.md, MODELS.md and README 2026-10-27 21:23:06 +01:00
Niko1221 845941f522 metal: cand_blocks ran the block maxima and returned before the block selection: contexts past 16K tokens read an unwritten candidate list (needle lost at 17K+) 2026-10-27 21:17:42 +01:00
Niko1221 e3ed743f21 metal: the expert share leaves room for other programs' anonymous memory (a 60 GB Qwen server beside the engine froze the M5 Max twice) 2026-10-27 20:54:49 +01:00
Niko1221 c7980a3fe4 docs/DGX_SPARK_LINUX.md for 0.2.7.1: the Terminal install, the packages, the settings, the memory, the speeds and the quality of the sm_121 defaults as measured on the release candidate 2026-10-27 19:36:46 +01:00
Niko1221 1a937829a5 Engine and Control 0.1.53 (the CMake project version, for the linux-aarch64 packages of 0.2.7.1)
Only the engine / Control number: the app, the Terminal and the Metal engine keep their versions here; the release bump of the other files
is the release's own commit.
2026-10-27 18:30:33 +01:00
Niko1221 773fc4e228 dgx_concurrent: --parallel N and --distinct (a prompt and a hidden code for every client, the answers checked: no error, readable, its own code and nobody else's) 2026-10-27 18:30:16 +01:00
Niko1221 732e4bfb60 DeepSeek on a Mac: drafter off in the config and the plan, per-set measured lines, plan words for a Mac that holds every expert 2026-10-27 18:07:26 +01:00
Niko1221 52ee842dbf test: the Mac launch list carries the two DeepSeek sets 2026-10-27 17:55:49 +01:00
Niko1221 b0e897b41f Tests: the terminal suites know a linux-aarch64 host (fixture Terminal named for the host; HIP-only tests skip where there is no HIP engine); pins and locks tests list the new target 2026-10-27 17:47:10 +01:00
Niko1221 6a51355051 metal runtime: the DeepSeek queue/residency/barrier rules sit behind STRATA_DS4_RUNTIME; strata_ds4 compiles its own copy of mtl_rt.mm and gpu/runtime.cpp, the Qwen engine keeps the 0.2.7 code 2026-10-27 17:46:26 +01:00
Niko1221 8f7d53ccd5 SetArgs: the Metal fields exist only in Metal builds (the CUDA/HIP kernels take the struct by value) 2026-10-27 17:38:12 +01:00
Niko1221 2cb47c34c2 DeepSeek on a Mac: install plan (text only, 8K, the two sets), tests 2026-10-27 17:32:29 +01:00
Niko1221 232fa357fd SetArgs: the Metal fields go last, so the CUDA/HIP kernels' parameter layout (and SASS) is exactly v0.2.7's 2026-10-27 17:30:28 +01:00
Niko1221 828fc80004 mac package script: --no-picture-reader (the DeepSeek package is text-only on a Mac) 2026-10-27 17:27:05 +01:00
Niko1221 1fca685d6b Mac DeepSeek engine package: strata-dsv41 entrypoint, configure+build in the script, build id and version in the binary 2026-10-27 17:25:26 +01:00
Niko1221 cb32d8758d dgx_gate_ref.py: the 24-prompt identity gate against a recorded answer file (the handover's quality24 JSON) 2026-10-27 17:22:14 +01:00
Niko1221 fa370e46d1 setup.py: DeepSeek on a Mac goes through install_dsv41 (Control mode); launch passes --feed/--dsv41-package on a Mac; model-list tests 2026-10-27 17:18:07 +01:00
Niko1221 2e7af09a86 DeepSeek on a Mac: the fit plan and the model list (dsv41_fit._plan_mac, strata_mcp._mac_deepseek) 2026-10-27 17:16:15 +01:00
Niko1221 b4814a9fb1 dgx_smoke: the setup check is read case-insensitively (setup prints ARM64) and reports the GPU line 2026-10-27 17:15:51 +01:00
Niko1221 d551ab0282 DeepSeek Metal port: DS4_FAKE_CPU_US reads its own value (build fix found by the HIP build) 2026-10-27 17:13:08 +01:00
Niko1221 43a328d8b6 Packaging docs for linux-aarch64; test_install_scripts_env knows install.sh now installs on Linux ARM64 2026-10-27 17:12:36 +01:00
Niko1221 e6fd914616 The Terminal on a GB10: its GPU is found, listed and planned as one unified-memory pool
nvidia-smi gives a GB10's memory.total as [N/A]. tools/strata_mcp.py's own NVIDIA probe (the fallback of the launch hardware page)
dropped the card; it now counts it as setup.py's gpus() does (arm64 only). strata doctor --live accepted no card with that figure; on arm64
it lists the card with no memory figure (x86 still refuses it). The DeepSeek fit plan for a unified-memory GPU with no BIOS carve-out
(GB10) no longer speaks of Windows, a BIOS and a Radeon, and quotes the numbers measured on the Spark. The Windows Strix Halo plan text
is unchanged (tests).
2026-10-27 17:12:36 +01:00
Niko1221 694e711708 The DGX Spark benchmark family and guide (from the owner's handover, 2026-10-26)
tools/dgx_bench.py, dgx_warm.py, dgx_compare.py, dgx_quality.py, dgx_quality24.py, dgx_needle.py, dgx_turns.py, dgx_longctx.py, dgx_128k.py,
dgx_kvresident*.py, dgx_concurrent.py and dgx_smoke.py (the acceptance gate in one command; its data folder is now an option and its scratch
files live in a folder of their own) and docs/DGX_SPARK_LINUX.md.
2026-10-27 17:12:36 +01:00
Niko1221 8544420180 nv_defaults_test: the sm_121 table, its two off switches (STRATA_SM121_PERF=0, STRATA_DEFAULTS=026) and that the other tables are untouched 2026-10-27 17:12:36 +01:00
Niko1221 f436dba81b The linux-aarch64 platform: engine packages, Control (Terminal) and the signed feed for the DGX Spark
- strata_control/targets.py: host_platform() names Linux ARM64 'linux-aarch64' (as uname and Tauri do; a Mac stays macos-arm64) and the
  cuda backend is committed to there (nothing else is: no HIP, SYCL or CPU engine for arm64 Linux)
- strata_control/feed.py: linux-aarch64 is a feed platform for engine, dsv41 and control packages (and a Tauri target name, no app is built)
- packaging/package_terminal.py: the Terminal for linux-aarch64 with python-build-standalone 3.12.15 aarch64-unknown-linux-gnu (SHA-256 from
  the release's SHA256SUMS); packaging/lock_wheels.py + locks/runtime-linux-aarch64-cp312.txt: its hash-pinned wheels (the same 23 packages
  as the x86_64 lock; platformdirs resolved to 4.13.6 against 4.13.0 there)
- packaging/make_local_feed.py: --dsv41 for the DeepSeek packages, linux-aarch64 terminals; packaging/install.sh installs on Linux ARM64
  and says the engine is for the DGX Spark (GB10) only, instead of refusing
- packaging/build_cuda_linux_aarch64.sh: the release recipe (strata + strata-dsv41 + the reader, the engine package and the separate
  DeepSeek package, sm_121 + compute_121, driver 580.65, build id naming the commit)
- tests: test_linux_aarch64.py, the Spark rows in test_release_matrix.py

Nothing changes for another platform: every edit is a new table row, a new branch for aarch64 Linux or a new file.
2026-10-27 17:12:36 +01:00
Niko1221 552c56f7a7 setup.py on a DGX Spark: unified-memory GB10, its engine from the signed feed, the measured run-config settings
- gpus(): a GPU whose memory nvidia-smi does not report (GB10) on arm64 is counted as Strix Halo's is: the RAM less the OS share
  (UMA_OS_LEFT_GB), uma True, dedicated_gb 0, so the low-RAM and fit rules do not count the same memory twice. #3622 listed the whole RAM
  as VRAM beside the RAM.
- cpu_info(): an arm64 CPU is named from lscpu (Cortex-X925 + Cortex-A725), not 'unknown CPU'. The PCIe probe is skipped for a
  unified-memory GPU (nvidia-smi's 1.0 x1 there means nothing).
- get_prebuilt(): on arm64 Linux the ready-made engine is the linux-aarch64 package of the signed feed (engine_from_feed); without one,
  setup compiles as #3622 had it do always. setup no longer forces --build on arm64.
- Step 7 on a GB10 (is_gb10: uma, sm_121, arm64 Linux, nothing else): --mmap-experts (one copy of the experts, all 24,576 cache
  slots), --prefill auto:32768, --mtp-q4 all, --mtp-hnorm stream, each measured on one GB10 with the answers unchanged; --draft-vocab
  en is printed as a tip, not set.
- The CUDA repository key URL is sbsa on arm64; the DeepSeek-engine-missing message names the Spark build script on arm64.
Tests: test_setup_arm64.py (unified-memory shape, is_gb10, lscpu name); test_setup_pins.py skips its x86-zip Engine tests on arm64 Linux.
2026-10-27 17:12:36 +01:00
Niko1221 e3d8823e6c DeepSeek on arm64 Linux: the same two compile fixes as the Metal branch (amd_family() on a non-x86 CPU, iq256x stubs in portable.cpp)
Taken verbatim from 756380301 so the two branches merge without a conflict.
2026-10-27 17:12:36 +01:00
Niko1221 9a398bd5bf sm_121 (GB10, DGX Spark) speed defaults and the fused prompt tile
nv_defaults.cpp gets an sm_121 table beside sm_86 and sm_60: STRATA_PF_FUSED=1 (fused IQ prompt kernels), STRATA_PREFILL_NVFP4=w4a4
(NVFP4 prompt MMQ with FP4 activations), STRATA_HC_UPMIX=1 (fused hyper-connection read), STRATA_SPEC_COST=1 (cost-aware MTP window).
Measured on one GB10 with paired A/Bs (docs/DGX_SPARK_LINUX.md); a switch the user set is kept. STRATA_SM121_PERF=0 turns the table
off, and so does the 0.2.7 master switch STRATA_DEFAULTS=026. Unlike the sm_86 / sm_60 tables these are not bit-exact (the fused prompt
path and the FP4 activations round differently; 24 of 24 greedy answers were identical), so the start line does not call them exact.

moe_fused_iq.cu: on a GB10 the work-item rule never reached its 128-row tile (the launch average is above 112 rows an expert at every
prompt size), so pick_ww takes the 128-row tile from 65,536 routed rows a launch up (+3% prompt at 16K and 32K; 4K keeps 64).
STRATA_PF_TILE_DEBUG=1 prints the choice. Other parts take the line they took before.
2026-10-27 17:12:36 +01:00
Niko1221 4e06c84b91 DeepSeek engine on an integrated NVIDIA GPU (DGX Spark): arm64 build and placement from MemAvailable
- ds4_gg_cpu.cpp: amd_family() on a non-x86 CPU read no variable the rest of the function uses; it now reports no AMD family
- exl3_cpu.cpp: kGemmK moved above the AVX-512 section, as exl3_cpu_gemm_scratch() sizes by it on every CPU
- ds4_model.cu: uma_free(): on an integrated NVIDIA GPU on Linux cudaMemGetInfo counts only MemFree, while the model files' page cache
  holds most of what the kernel can still give back. The placement, the CPU-lane warm-up and the vision tower size their room from
  MemAvailable less DS4_UMA_HEADROOM_GIB (default 6) when that is larger. A discrete GPU, HIP and Windows are not touched.
  On a GB10: 12,163-12,226 of 15,744 experts in graphics memory where cudaMemGetInfo's free placed 11,961.
2026-10-27 17:12:36 +01:00
Niko1221 f262b81b28 Engine for the DGX Spark: the arm64 CUDA build on top of #3622
#3622 made the strata target compile on aarch64. This adds what a Spark build needs beyond that:
- the trellis dispatcher (tq_cpu.cpp, its scalar rows) is part of the arm64 kernel library, as it is in the Metal build, so the
  DeepSeek engine's CPU lane links; ds4_tq_cpu.cpp names the AVX units on x86-64 only
- a STRATA_PORTABLE build on arm64 sets ggml-cpu's -march to armv8.2-a+dotprod+fp16+i8mm (STRATA_ARM64_ARCH) instead of the build
  host's; GGML_NATIVE OFF alone would leave plain armv8-a and no NEON dot products
- the x86 kernels' parity programs and benches are not configured on arm64; the test suite defaults off there (-DSTRATA_BUILD_TESTS=ON
  still builds what builds)
- spark/build_engine.sh: the reproducible build of strata, strata-dsv41 and the picture reader into an engine folder, with the CUDA
  libraries beside them (RUNPATH $ORIGIN)

Every change is behind STRATA_CPU_X86 being OFF or inside a new file; the x86 and Metal builds do not take it.
2026-10-27 17:12:36 +01:00
Niko1221 56ab127c87 DeepSeek Metal port: keep the CUDA/HIP paths as in 0.2.7 (chunk-path hc mix through cuBLAS, async embedding upload, no extra h16 rows, no diagnostics on hot paths, type_on_chunk true off Metal) 2026-10-27 17:07:21 +01:00
Niko1221 5ed8ae03be launch header: exl3.cu includes it for every backend; exl3_cpu kGemmK only off x86 2026-10-27 17:01:02 +01:00
Niko1221 756380301b DeepSeek on Metal: compile fixes on the 0.2.7 tree (huge pages, wmma include, DeviceProp.integrated, iq256x stubs) 2026-10-27 16:49:46 +01:00
Niko1221 314a6ecf69 metal: the host sources no longer list iq256_portable.cpp (portable.cpp answers for non-x86 hosts) 2026-10-27 16:45:06 +01:00
Niko1221 8477811197 Merge f-dsv41-mac (DeepSeek on Metal) into v0.2.7
The 0.2.7 DeepSeek changes (persistent-grid IQ kernels, int8 rounding batches, hc_mix_split with a block counter, head columns,
chunk-path knobs) are kept; the Metal engine's hooks sit on top of them: every kernel launch is strata_ds4_launch(), the rounding
batch ends in the Metal multi-quantiser dispatch, the head stays at 4 columns a pass on Metal (8 moves the KL), and the macOS
parallel expert prefetch is macOS-only.
2026-10-27 16:44:27 +01:00
Niko1221 1b667c7abb WIP not-default: DS4_NO_WAIT_SPLIT (no commit at the routing wait; A/B unfinished) and DS4_PHASES (decode host phase timers) 2026-10-25 12:44:45 +01:00
Niko1221 4acede655a DS4_PLACE_HASH: one hash of which experts hold a VRAM slot (the placement changes with the saved profile) 2026-10-25 12:23:42 +01:00
Niko1221 1ece7efd5c metal: no command-buffer split after the fp8-rounding finishes (decode +22%: code 11.9 -> 14.6, agent 9.7 -> 12.0 tok/s); DS4_FINISH_SPLIT=1 restores it 2026-10-25 10:38:07 +01:00
Niko1221 2a207dbfcc metal: sparse attention on simdgroup matrices is the default for 8-head groups (32K prompt 269 -> 293 tok/s, KL equal to the scalar kernel); DS4_ATTN_MX=0 for the old one 2026-10-25 08:46:05 +01:00
Niko1221 b6dcb23ec6 WIP metal: ds4_sparse_attn_mx (sparse attention on simdgroup matrices, DS4_ATTN_MX=1; =2 compares with the scalar kernel: rms rel 1.8e-6); timing and KL gate pending 2026-10-25 02:07:40 +01:00
Niko1221 100a2374fb metal: Q5_K dequant four values a step (bit-identical); DS4_SKIP_K and the timeline's overlap count as debug tools 2026-10-25 02:37:35 +02:00
Niko1221 8810e00c09 metal: prompt hc_mix reads each token's row once for four mixes (bit-identical, +4.5% on the 3K prompt) 2026-10-25 01:49:44 +02:00
Niko1221 050752f5b9 DeepSeek Metal: head matvec back to 4 columns a pass (8 moved the KL by +0.002) 2026-10-25 01:41:34 +02:00
Niko1221 97c091c5be DeepSeek Metal: prompt flow reads experts straight into the ring (unified memory: no staging, no DMA), head matvec 8 columns a pass 2026-10-25 01:24:37 +02:00
Niko1221 6b2a530393 DeepSeek Metal: cached compute pipelines (the prompt path created one per expert copy), F_NOCACHE for the unbuffered expert reads, dispatch-seq log 2026-10-25 01:10:26 +02:00
Niko1221 e527bfefc1 DeepSeek Metal: GPU stale-router lookahead prefetch (opt-in DS4_LOOKAHEAD; measured slower), recall counters 2026-10-25 00:28:37 +02:00
Niko1221 9e3fe0461c DeepSeek Metal: CPU-lane page prefetch in parallel across the call's matrices (the cold reads were 78% of the lane), CPU-lane phase log 2026-10-25 00:06:36 +02:00
Niko1221 90359513e3 DeepSeek Metal: DS4_BUSY_ALL experiment (off), progress lines 2026-10-24 22:18:40 +02:00
Niko1221 8d3c976166 DeepSeek Metal: keep the GPU busy (flag-released spin kernel) while the CPU lane runs: decode +25-35% 2026-10-24 21:47:29 +02:00
Niko1221 dde3250cdb DeepSeek Metal: --dspark-depth, dispatch counts in the round times 2026-10-24 21:28:44 +02:00
Niko1221 958774b4ce DeepSeek Metal: sampler statistics harness, round time breakdown 2026-10-24 21:10:17 +02:00
Niko1221 f29ff5a59a DeepSeek Metal: drafter bring-up (ring restore, host sampler for temperature and speculative sampling), per-dispatch GPU timeline 2026-10-24 20:44:05 +02:00
Niko1221 34810a3bb3 DeepSeek Metal: automatic VRAM-expert share from the anonymous-memory cap (explicit --vram-experts wins) 2026-10-24 20:09:03 +02:00
Niko1221 5dce2f63e9 DeepSeek Metal: batched q8 quantiser, zero-copy expert mapping (DS4_ZC), spin-wait, GPU-busy and timing switches 2026-10-24 20:02:54 +02:00
Niko1221 c2675c60d1 DeepSeek Metal: chunk path mixes through the fused hc_mix kernel 2026-10-24 19:12:34 +02:00
Niko1221 700def65a2 DeepSeek Metal: chunked prompt path correct (sgemm on the one Context, KEEP dump syncs, debug switches); 2988-token prompt 251-277 tok/s vs 32 in windows 2026-10-24 18:56:42 +02:00
Niko1221 58e5d7851e DeepSeek Metal: chunked prompt path (MPP expert tile GEMMs, dense gemm/dq, rows_f16), NC-column matvecs, dangling buffer and null-arg offset fixes (WIP, chunk output not yet matching window path) 2026-10-24 18:07:46 +02:00
Niko1221 7c77f388dd metal: commit the queued GPU work before the CPU lane runs (they overlap) 2026-10-24 16:11:09 +02:00
Niko1221 42a982e033 metal: copies and fills ride the context's command buffer, events mark it, buffers chained by a shared event, decode matvecs for IQ2_XS/IQ2_S/IQ3_XXS 2026-10-24 15:59:04 +02:00
Niko1221 3e51b1c18a metal: decode matvecs for Q5_K and IQ2_XXS (every lane busy, 16-byte loads), lane-parallel hc_split for hc 4, DS4_PROF per-kernel GPU table 2026-10-24 15:44:39 +02:00
Niko1221 9dbb13ee89 metal: the IQ2_XXS matvec read its sub-blocks 4 bytes apart (8), IQ2_XS signs, IQ3_XXS matvec added; more dumps and checks 2026-10-24 15:15:20 +02:00
Niko1221 860e384db7 metal: the ring backup flag, decode takes the host-routed path, split commits sync the streams first, sparse_attn sizes its binding without a host read of device data 2026-10-24 14:33:07 +02:00
Niko1221 193529239c metal: finish dst stride, BF16 ring pack, residency on the context queue, split the command buffer after fp8 finishes; ds4_launch.h makes the CUDA build work again; more DS4_DUMP_S points 2026-10-24 12:41:49 +02:00
Niko1221 9f45341966 metal: bind the allocation's own MTLBuffer instead of a no-copy alias per call (the alias read stale data), deferred frees, matvec jobs cut at 8 activations, Q8/quant debug checks 2026-10-24 11:47:51 +02:00
Niko1221 da9cf6354f ds4: DS4_DUMP_S sub-step dump inside the layers (same records on CUDA and Metal), DS4_FLUSH_EACH, per-layer debug prints 2026-10-24 11:25:36 +02:00
Niko1221 0c34a7de3c metal: both queues drain at every sync, engram dequant wraps the strided extent, the ggml CPU lane is in strata_ds4
Window 2's residual stream was window 1's stale tail: stream copies and mtl_rt's deferred command buffer had no
ordering at sync points (memcpy, stream sync). Now a full barrier over all streams and the context, and the context
takes a lock (helper threads flush too). engram_dequant_rows wrapped n*18/32*hd bytes but strides rows by hd: rows
past 192 of 288 read unmapped memory (inf scales, router picked INT_MAX). The CPU lane (ggml-cpu rows) was compiled
out, so every non-resident expert contributed zero. Debug: DS4_DBG_ALL (every layer), DS4_DBG_EMB, DS4_DBG_EG.
2026-10-24 11:14:02 +02:00
Niko1221 97637e2a14 metal: init_pre no longer wraps a whole page to reach 32 bytes
init_pre writes T*8 floats - 32 bytes for T=4 - and its wrapper wrapped the enclosing 16 384-byte page to bind them.  That aliasing
MTLBuffer overlaps the arena pages that hold h, and with it present h read back as 'valid at both ends, NaN in the middle' with
nan_range starting at exactly 4 096 - one wrapped page of floats.  The work is trivial, so it now runs on the CPU through host_view:
no wrap, no alias, no dispatch.

Result with the fix: h at layer 0 is clean (zeros=40960, ninf=0, max=0) instead of ninf=4096 max=7.6e+07.  The output is still
0,0,0,... so embed's write still does not reach h - but the contamination that had been confusing every probe of that buffer is
gone, and the two remaining readings (host_view vs cudaMemcpy on h between embed and layer 0) can now be compared cleanly.

Fixes this session, all measured: the 1.32 GB all-zero embedding file (my converter read the sparse trial shards), my own
20000-dispatch flush cap (it blocked every prompt of 256+ tokens), and now the init_pre page alias.

Targets, unmet: decode 8.94 tok/s vs 100, prompt 4000 tokens ~27 tok/s vs 1000, and no valid token has ever been produced.
2026-10-24 05:31:28 +02:00
Niko1221 782a8381de metal: probes that establish what is NOT wrong, and the honest state of the targets
Measured this session, in order of how much they narrow the problem:

* the pack's embedding table is real, and the engine hands embed correct rows (rows_in: first=4759 4759 4759 4759)
* ds4_embed's kernel receives the right arguments (args probe: dim=5120 hc=4)
* one Dev, one h: DBG devh prints id=0 h=0x100011a8000, the same address layer() reads
* the memory model is sound: writing through host_view round-trips through cudaMemcpy at 4 KiB and at 64 MiB (42 -> 42)
* both dispatch routes work: a constant written via Arg::B reads back 3 3 3 3, via a device pointer passed by value 7 7 7 7
* the Metal embed path is load-bearing: breaking its pipeline changes the exit code 0 -> 3

What is still wrong: h is partly NaN at layer 0 (nan_range=4096..40703, max=7.6e+07) with valid data at both ends, and
wholly NaN from layer 1, so the logits never resolve and every run prints 0,0,0,...   Nine hypotheses were tested and
disproved today: the zeroed table (a real bug, fixed, not the cause), two queues, host_view vs device pointer, memory
coherence, the binding, a dead Metal branch, a wrong dim, a short write, and two Dev records.

Targets, measured, unmet: decode 8.94 tok/s vs 100; prompt 4000 tokens ~27 tok/s vs 1000.  The 51.8 and 176 tok/s reported
earlier were dispatch-ENCODING rates and are withdrawn here as they were in the notes.

Next: locate the real d.h allocation (several probes landed on paths the engine does not take) and read that buffer at
three points in one run - at allocation, after embed, and at layer 0.
2026-10-24 05:29:05 +02:00
Niko1221 c763c12ac5 metal: the layer reads a different h buffer than embed fills - measured with addresses
embed writes   h = 0x144f24000
    layer 0 reads  h = 0x100011a8000

Both report device 0, and forcing --gpus 0 does not change it, so it is not a tensor-parallel artefact: the engine holds two Dev
records with two h allocations, embed fills one and layer() reads the other, and the bounce in ds4_model.cu (if (l.dev != cur)) is
meant to reconcile them but does not on this path.

That single pair of addresses explains every symptom: layer 0's input buffer was never written (hence zeros=40960), the first block
then produces NaN, and the logits never resolve.  It also retires the last four hypotheses: the embedding table (real), the embed
kernel (verified: writes as_type<float>(0x4759 << 16)), two-queue ordering (shared Context now), and stale host reads (the probe
flushes).

Correcting the record: the 51.8 tok/s decode and 176 tok/s prefill reported earlier were dispatch-ENCODING rates and are withdrawn.
The real measured rates are 8.76 tok/s decode and ~27 tok/s at 4000 tokens, and no valid token has ever been produced.  Targets remain
100 tok/s and 1000 tok/s: both unmet.

Next: print cur, l.dev and the two h addresses inside layer() - whichever differs is the buffer the embedding never reached.
2026-10-24 05:13:47 +02:00
Niko1221 00e7626771 metal: embed verified correct at the kernel; the value is lost before layer 0
Measured this stretch:
* the pack's embedding table is real (embed_pinned = 3d6b 3e6e 3e65 3dd7)
* embed itself is correct: immediately after the call h[0..3] reads 55296, which is exactly as_type<float>(0x4759 << 16),
  the BF16 embedding value converted the way both ds4_embed and embed_kernel convert it
* yet at the top of layer 0 h is all zeros (zeros=40960 max=0), and block 1 turns that zero into 100% NaN

Nothing between the two probes writes h except init_pre (which writes pre).  The remaining suspect is which Dev's h the layer reads:
the engine has a cross-device bounce (cudaMemcpyAsync through a host staging buffer) that ds4_metal_flush() does not cover, and a
stale l.dev under 'tensor parallel 2 -> 1' would produce exactly this.

Three hypotheses about the embedding are now retired with evidence: the zeroed table (a real bug, fixed, did not change h), two-queue
ordering between init_pre/embed and the rest (they now share one Context, no change), and the kernel writing nowhere (it writes
correctly).  Targets remain unmet: decode 8.76 tok/s vs 100, prompt 4000 ~27 tok/s vs 1000, and no valid token has been produced.
2026-10-24 05:11:14 +02:00
Niko1221 ba9e818c42 metal: drafter path ported, token-boundary commits, and the embedding blocker measured end to end
State of the port, honestly: the engine runs, 40+ kernels dispatch, the pack and the 110 GB Engram are real, and it has NEVER
produced a valid token.  Both targets are unmet: decode 8.76 tok/s (target 100), prompt 4000 tokens ~27 tok/s (target 1000).

What this commit adds:
* the drafter path: ds4_mx_gu/ds4_mx_dn (MXFP4 prompt gate_up/down, one runtime-ne kernel), ds4_markov_add, ds4_confidence, with
  hosts and guards.  --dspark on now runs end to end, but the confidence is NaN (its taps come from the trunk, which is NaN), so no
  drafts are offered and there is no drafter speedup.
* token-boundary commits: seven host-read sites in ds4_model.cu flush the deferred command buffer before reading a device result.
  This is what exposed that the earlier 51.8 / 176 tok/s figures were dispatch-ENCODING rates, not GPU rates: with the work inside
  the timed window the real numbers are 8.76 tok/s decode and ~27 tok/s at 4K.  Those earlier numbers are withdrawn.
* embed/init_pre now dispatch through the shared Context (they had their own Metal queue, so no ordering against the copies that
  feed them) - measured, and it did NOT fix the blocker.
* the embedding blocker, measured at both ends: the pack's table is real (embed_pinned reads 3d6b 3e6e 3e65 3dd7), ds4_embed is
  dispatched at ds4_model.cu:7490 and returns, and h[0..3] is 0 0 0 0 immediately afterwards, so the kernel writes nothing into the
  buffer the engine reads.  Behind it, block 1 turns a zero input into 100% NaN (a 0/0 in the first norm or router).
* env-gated debug probes (DS4_DBG_H, DS4_DBG_EMB, DS4_DBG_L) left in for the next session; they are inert without the variables.

Known edit hazard, learned three times: patching by a generic statement pattern lands in the wrong function.  Patch by unique
context or by function name.
2026-10-24 05:09:14 +02:00
Niko1221 1a80ccc981 metal: histogram off the hot path, dedupe and host_view experiments reverted, chunk sweep recorded
Removed the per-dispatch histogram from encode() (it cost a mutex and a string key per dispatch; measurement showed no gain, so the
~10 us a dispatch is genuine encode + engine host work).  Reverted the two experiments that did not hold up: the q8_quant dedupe
(the repeated calls all differ - 0 dispatches saved) and skipping host_view() in cpu_addr (breaks the engine, 15 dispatches).

Prefill chunk sweep, 4000 tokens, pack-2.25-real:  --prefill-chunk 16 -> 165.90 tok/s, 32 -> 175.89, 64 -> 176.26.  The engine runs
16-token windows regardless, so the curve flattens: 176 tok/s is the 4K number and the target is 1000.

Measured state: decode 51.8 tok/s (target 100), prompt 4K 176.3 tok/s (target 1000).  Neither met; the remaining levers are kernel
fusion for decode and a key-tiled attention kernel for prefill, both rewrites.
2026-10-24 04:04:26 +02:00
Niko1221 0b2201ce44 metal: the 20k flush cap was the 256+ prompt failure; 4K now runs at 171 tok/s, and the launch histogram names the decode cost
Two measurements tonight that matter:

1. Every prompt of 256 tokens or more died at exactly '20073 dispatches, 2 command buffers'. That is the deferred command buffer's 20,000
   dispatch cap - my own constant from when I introduced batching - flushing mid-token. Raising it to 2,000,000 makes the long prompts run:
     64 tokens   535.8 tok/s
     128 tokens  556.3 tok/s
     256 tokens  542.6 tok/s  (31,508 dispatches, 1 command buffer)
     1024 tokens 451.0 tok/s  (126,020 dispatches, 1 command buffer)
     4000 tokens 170.9 tok/s  (492,254 dispatches, 1 command buffer)
   The curve falls with length, so the 4K figure is attention scaling, not fixed overhead.

2. The per-kernel dispatch histogram now works (Pipeline carries its name; the earlier '<unnamed>' was a lookup bug):
     ds4_q8_quant      4122   (36%)
     ds4_iq_mv_k       1218
     ds4_finish        1038
     ds4_hc_pre_norm    486 / hc_split 480 / hc_mix 480 / hc_post_v 480
     ds4_gemv_f32       474
   ~1,890 dispatches a token at 8.8 us each = the 16.7 ms a token, so decode speed is 1/(launches x 8.8us). 100 tok/s needs
   ~1,130 launches: dedupe q8_quant per layer (-380) and fuse the four hc_* passes (-320) gets there arithmetically.

Decode 51.8 tok/s (target 100, measured on pack-2.25-real), prompt 4K 170.9 tok/s (target 1000). Neither target met.
2026-10-24 03:57:34 +02:00
Niko1221 c486f17251 metal: sparse attention + prompt matvec columns + exact wrap extents - prefill measured at 556 tok/s
Measured (real 110 GB Engram pack, engine's own lines):
  prompt  64 tokens -> 535.76 tok/s
  prompt 128 tokens -> 556.31 tok/s
  decode            -> 48.3-50.0 tok/s
  prompt 256+       -> aborts, router sentinel 2147483647 (no pps figure)
Targets are 100 tok/s decode and 1000 tok/s prompt: both roughly 2x away, neither met.

Added:
* sparse_attn_packed<G> (G=8/4/2/1) and the BF16 row variant, with the host wrapper that mirrors AttnRows and picks G - the 4K
  abort at ds4_kernels.cu:2161 is gone.
* ds4_iq_mv widened to 16 accumulators; host chunks any job with ncol > 16 into 16-column jobs (this prompt needs ncol=96), and
  Q5_K/Q6_K dense matrices get ds4_iq_mv_k instead of being fed to the IQ2-only decoder.
* route, gather_rows, combine kernels.
* Wrap extents made exact across 7 wrappers (moe_sort, swiglu, hc_split scale/base, sparse_attn rows, unpack_sel, engram_dequant);
  the old 'bytes + 65536' form could overrun an arena chunk's edge.

Known bug, not fixed: for prompts of 256 tokens or more the attention SINK logits read NaN (sink[57] at the first failing window),
which poisons the softmax denominator, then q, then the router. 64 and 128 tokens are clean, so it looks like a path switch at the
256 boundary (few = T <= kAttnScratchT in sparse_attn) or an overrun into the sink buffer.  That is what stands between the current
556 tok/s and a 4K figure.
2026-10-24 03:38:28 +02:00
Niko1221 611530d992 metal: dispatches BATCH into one command buffer - decode 0.16 -> 48.7 tok/s (300x)
Context::run() committed a command buffer and blocked in waitUntilCompleted for EVERY dispatch. At ~60 dispatches a layer over 40
layers that is 3.3 us x 2400 plus a 133 us commit+wait each, and it showed up as exactly the 330 ms a token the engine reported.

Now a single dispatch is appended to a persistent command buffer and returned from without waiting; the buffer is committed when it
fills (20000 dispatches) or when the engine synchronises (strata::gpu's stream/device synchronise call ds4_metal_flush()).

Measured on the same fast harness (--vram-experts 0.05 --cpu-threads 2, ids 1,2, n=6):
  before: prompt 5.43 tok/s, decode 3.00 tok/s, layers 330.1 ms/token
  after:  prompt 77.0 tok/s, decode 48.7 tok/s, layers  19.1 ms/token
with the experts resident in VRAM instead of streamed: decode 42.6 tok/s (layers 19.5 ms) - so residency is no longer the limit.

The remaining gap to 100 tok/s is the 1-token IQ2 matvec itself: with ncol == 1 the eight accumulators are idle and every weight is
decoded for a single dot, which is the 87.7 GB/s case measured earlier (versus 437 GB/s at an 8-token window). Fixing that is kernel
work, not runtime work.
2026-10-24 01:57:12 +02:00
Niko1221 33bf441cd4 metal: THE ENGINE GENERATES TOKENS ON THE MAC - 17 more kernels, the whole path runs
Official harness completes with no abort: /tmp/r.out = '0,0,0,0,', zero 'no Metal dispatch', zero metal errors.  Prompt 4 tokens in
5112 ms, decode 4 tokens in 18876 ms.  The ids look degenerate and numerical parity is NOT established - the Engram in this pack is
zeros, and a wrong kernel would produce the same output - so what is proven is that the engine walks the entire path and emits ids,
nothing more.

Kernels added (each verified by the engine's next abort naming a later or different launch):
  prompt expert GEMM: gate_up_kernel<T,NE>, down_kernel<T,NE>  (ds4_iq.metal, with IQ3_XXS type 18 joining the decoders - this
  pack mixes gate types 16/17 with down types 18/22)
  mx_combine, engram_dequant (fmt 2), engram_gate, bf16_mv (engram wkv), compress, index_score, topk (radix with simd_ballot),
  finish_kernel<true> (KV pack - the call site had no guard at all), finish rounding branch, gemv_f16_rows, block_max, hc_mean,
  iota_rows, sampler_greedy  (ds4_init.metal, 1521 lines)
  mx_wait skipped under STRATA_METAL, like the pre-existing wait_seq no-op

Also fixed a real pre-existing bug: the 'payload + 65536 from the page base' wrap convention walked off the mapping when a small
buffer sat at an arena chunk's edge (flaky 'mtl: wrap 81920/98304 failed', exit 4, 2 runs in 4).  ds4_finish.cpp now wraps exactly
the pages a buffer spans.

Not ported: sampler_kernel<0> (the sampled path; the harness is greedy), engram fmt 0/1 (this pack is fmt 2), bf16_mv's non-vector
variant.  Expert types beyond 16/17/18/22 make the IQ kernels return false, so the CUDA launch names the line instead of a silent pass.
2026-10-24 01:51:31 +02:00
Niko1221 154c2990ed metal: the routing trio ported ahead of its turn - moe_sort counts, bounds scan, fill
ds4_bounds_scan, ds4_moe_sort_count and ds4_moe_sort_fill (ds4_kernels.cu:1938-1943) with the host wrapper that launches them
in order on one command buffer (serial dispatch, so call order is execution order).  kPicks = 8: idx is [T][8], q -> (q/8,
q%8); one threadgroup a virtual slot, a block-wide prefix over 8 warps, row_tok/pos written only by the filling lanes.

One Metal fact paid for: simd_ballot returns a simd_vote that does not cast to an integer here.  The counts come instead from
simd_prefix_exclusive_sum (a lane's offset inside its simdgroup) and simd_sum (the group's total) - which is a cleaner
formulation than CUDA's ballot+popc anyway.  AOT: 20,160-byte AIR, rc=0.

Correction worth recording: the engine's current gate is NOT moe_sort.  It still stops at ds4_kernels.cu:1915, i.e. the Engram
row decoder (engram_dequant_tok) - I attributed :1915 to moe_sort two briefs ago and that was wrong.  The routing trio is ported
and wired but not yet reached; its turn comes after the Engram decode.
2026-10-23 19:51:04 +02:00
Niko1221 4735e381ad metal: hc_split ported - the hyper-connection family is complete on the Mac
ds4_hc_split is the SCALAR path of ds4_kernels.cu:268: lane 0 of a 32-thread group does the whole token in a local 8x8 matrix -
sigmoid pre/post, row softmax with max shift, +eps, column normalise, then iters-1 alternating row/column normalisations.  That is
the same mathematics as the CUDA warp-lane version for hc == 4, without the shuffles: correctness first, the lane-parallel form can
follow if it ever shows in a profile.

The first attempt segfaulted (rc 139) and the cause is worth remembering: scale and base were memcpy'd as if they were plain host
pointers, but every operand the engine hands a kernel is a device buffer - reach it through strata::gpu::host_view() and pass it as
a wrapped Buffer with its offset.  With that fixed the engine advanced from :1908 to :1915.

Queue, nine items, each verified by the engine advancing: init_pre, embed, cublasSgemm, finish, hc_post, hc_mix_scale, hc_mix,
hc_split -> next is engram_dequant_tok (:1915), the Engram row decoder whose maths was verified standalone earlier.
2026-10-23 19:46:42 +02:00
Niko1221 7022bd25d8 metal: the hyper-connection family on the Mac - hc_post, hc_mix_scale, hc_mix ported
Three more kernels, each confirmed the only way that matters: the engine moved past the launch line it had been stopping at.

* hc_post_inplace (ds4_kernels.cu:1677) - h[t][k][e] = post[t][k]*y[t][e] + sum_j comb[t][j][k]*h_old[t][j][e]
* hc_mix_scale (:1692) - mixes[t][i] *= rsqrt(sum(h[t])^2 / hcdim + eps)
* hc_mix (:1901) - mixes[t][m] = (h[t] . fn[m]) * rsqrt(mean(h[t]^2) + eps)

all in ds4_init.metal with host glue in ds4_finish.cpp (which is now the home of the small kernels: it owns the Context, the
pipeline cache and the wrap/offset helpers).  Queue so far, seven items, every one verified by the engine advancing:
init_pre, embed, cublasSgemm, finish, hc_post, hc_mix_scale, hc_mix -> next is hc_split (:1908).

hc_split is NOT a transcription: its softmax/sinkhorn is expressed in warp-lane geometry (shfl_xor masks 1,2 for rows and 4,8
across columns over lanes 0..15, for hc == 4) and it loops 'iters' times.  A wrong lane mapping yields a plausible but skewed
combination matrix, so it needs a parity test against the CUDA version on synthetic mixes before it is trusted.
2026-10-23 19:43:41 +02:00
Niko1221 5d969adde9 metal: finish_kernel<false> wired - the engine passes it and names item five
ds4_finish.cpp binds the engine's x as a wrapped buffer with an offset, passes Ds4Finish as a constant struct whose pointer fields
are the engine's gpuAddresses, and dispatches one launch with grid = n_vec (the kernel's blockIdx.x is the threadgroup index).
It declines the mrope (rd > 0) and rounding (quant != 0) branches and the KV pack, so those still abort with the launch line
rather than being skipped.

Verified the only way that matters: the engine no longer stops at :1894, it stops at ds4_kernels.cu:1921 (hc_post_inplace_kernel).
Queue so far, each step confirmed by the engine advancing: init_pre, embed, cublasSgemm, finish.

Two plumbing lessons: ds4_finish.cpp had to be added to STRATA_DS4_SOURCES (strata_ds4 does not use the engine-source glob), and
scp with a relative remote path put it nowhere - absolute paths from now on.
2026-10-23 19:40:19 +02:00
Niko1221 cb931e7010 metal: finish_kernel<false> in MSL, verified by the AOT compiler
ds4_finish (ds4_kernels.cu:1894) with its Ds4Finish struct and the block reduction: warp_sum over the 256-thread group
(simd_shuffle_xor 16,8,4,2,1 into red[8], barrier, second warp_sum, barrier), the RMS path (inv = rsqrt(tot/len + eps), then
r *= inv * w[e]) and the store (BF16 round to nearest even into dst, or back into x).

AOT gate: xcrun metal -c -> 10,032-byte AIR, rc=0.  Two Metal constraints cost a round each and are worth remembering:
'long long' is not supported (use 'long'), and a kernel parameter must be a buffer/constant reference, not a plain value.

Not wired yet: the host side still has to bind the Ds4Finish struct (pointers as 64-bit gpuAddresses, strides as long), and the
mrope (rd > 0) and rounding (quant != 0) branches are not written - the host wrapper will decline those until they are, so the
engine names the line rather than skipping a step.
2026-10-23 19:37:54 +02:00
Niko1221 b5b4a97c0a metal: the mHC dense product wired - cublasSgemm dispatches an MSL kernel instead of aborting
ds4_model.cu:2737 is the engine's first dense call: cublasSgemm(OP_T, OP_N, n_mix, T, hcd, &one, fn, hcd, h, hcd, &zero,
mixes, 32) - mixes[t][m] = h[t] . fn[m], both operands F32, alpha 1, beta 0.  ds4_sgemm_tn (one thread an output element) plus
metal_host::sgemm_tn wrap the engine's buffers and dispatch it; cublas_v2.h calls it for exactly that case and keeps aborting
for everything else, so an unported shape fails loudly rather than computing something wrong.

With that the engine passes the dense product and names its next need: finish_kernel<false> (the plain variant - RMS norm,
mrope, optional in-place rounding, no KV pack) at ds4_kernels.cu:1898.

Queue so far, each step verified by the engine advancing: init_pre, embed, cublasSgemm, then finish_kernel<false>.
2026-10-23 19:35:42 +02:00
Niko1221 45cbb451e7 metal: the engine runs on Metal and names its kernel queue; two kernels ported
The DeepSeek engine now loads a real 48,786-tensor pack on the Mac, plans the staging (102.26 GB of experts in unified memory,
5,604 on the CPU lane), and executes until it needs a kernel that has no dispatch.  Two are ported:

* init_pre (ds4_kernels.cu:1865) - a three-line initialiser
* embed (ds4_kernels.cu:1873) - the BF16 gather with hc copies

each as MSL in metal/src/engine/msl/ds4_init.metal plus host glue in metal/src/engine/ds4_host.cpp (wrap the engine's own device
buffer through strata::gpu::host_view + mtl::Context::wrap, then Dispatch), wired into the shared call site under
#if defined(STRATA_METAL).  The engine advanced past both and now asks for finish_kernel at :1896.

Two infrastructure fixes were needed and are in here:

* runtime.cpp: StreamImpl's encoder/command-buffer state is touched from the loader and the compute path, so two encoders could be
  open on one command buffer and Metal asserted ('A command encoder is already encoding').  A std::recursive_mutex around
  encoder()/flush()/encode_copy()/encode_fill()/encode_dispatch() fixes it - recursive because encode_copy calls flush (a plain
  mutex deadlocked, which cost one round).
* ds4_host.cpp compiles its MSL relative to the context's kernel_dir (metal/kernels), so the engine's own kernels are
  ../src/engine/msl/<file>.

Known: this build has STRATA_NATIVE_EXPERTS=OFF, so the engine routes all experts to VRAM ('this build has no ggml-cpu'); the
CPU-lane path is untested on the Mac.
2026-10-23 19:32:42 +02:00
Niko1221 15f1fe0548 metal: a one-Context allocator for the dsv41 engine, opt-in so the default path is untouched
metal/src/engine/ds4_ctx.cpp owns one mtl::Context, allocates through Context::alloc and remembers gpuAddress -> Buffer, so the
engine's device memory can live in the same residency set as the dispatches.  Off unless STRATA_DS4_METAL_CTX=1, which keeps the
current behaviour and makes the two paths comparable the moment a pack exists.  Build rc=0.

It exists because strata::gpu (what metal_compat's cudaMalloc maps to) is a CUDA-shaped facade with its own device state: a dsv41
launch through mtl::Context would otherwise read a foreign residency set.  Flipping cudaMalloc/cudaFree to this under the env var
is the two-line change, and it is deliberately left for a session that can test it against a loaded pack.
2026-10-23 18:56:42 +02:00
Niko1221 5f6d49e959 metal: the first dsv41 host wrapper - compile, wrap, dispatch (and the one-Context invariant it exposed)
metal/src/engine/ds4_host.cpp compiles ds4_quant.metal once through mtl::Context::compile, wraps the engine's buffers with
cx.wrap(strata::gpu::host_view(...)), builds a Dispatch (args = buffer, inline ldx, q, d; grid = n/32; tg = 32) and runs it with
cx.run().  Build rc=0 - the port's integration path is now code, not a plan.

It also exposed the last architectural question, which the comments record: the Qwen engine allocates and dispatches entirely
inside mtl::Context (cx.alloc / cx.wrap / cx.run) and uses strata::gpu only for a purge helper, so a dsv41 kernel must be given
the gpuAddress of a Buffer allocated by the SAME Context that encodes its dispatch.  Until metal_compat's allocations go through
that Context, the wrapper compiles but would dispatch into a foreign residency set - the plan for that is written up in
RIGS/m1002.md rather than guessed at here.
2026-10-23 18:54:37 +02:00
Niko1221 2cc55f6165 metal: the IQ2_S expert kernel, tiled - 515.7 GB/s effective, 89 % of the streaming ceiling
metal/tests/dsv41/iq2s_t8_parity.mm: block 82 B (f16 d, 32 index bytes, 32 sign bytes, 8 qh, 8 scale), grid index assembled
from qs[4ib+l] plus two bits of qh[ib] (<< (8-2l) & 0x300), signs read at qs+32 in the block, scale alternating db[l/2] -
transcribed from ggml's dequantize_row_iq2_s.  Verified: 65,536 rows x 5,120 k x 8-token window, rel L2 2.86e-07, PASS.

Rate 515.71 GB/s effective (64.46 GB/s of raw weight traffic) - the fastest of the three expert formats and 89 % of the
579 GB/s streaming ceiling.  With IQ2_XXS (437.5) and IQ2_XS (467.8) the 89 GB of IQ2 weight in the 2.25bpw set now has
verified Metal kernels, which is 165-195 tok/s of headroom on expert traffic alone.
2026-10-23 18:51:05 +02:00
Niko1221 446a45f750 metal: the IQ2_XS expert kernel, tiled - 467.8 GB/s effective at 97 MB
metal/tests/dsv41/iq2xs_t8_parity.mm: block 74 B (f16 d, 32 uint16 qs, 8 scale bytes), grid = iq2xs_grid[qs & 511],
signs = ksigns_iq2xs[qs >> 9], two scales a group (sc & 0xf, sc >> 4) - transcribed from ggml's dequantize_row_iq2_xs and
verified against the CPU reference: rel L2 2.83e-07 at 24 MB and 2.88e-07 at 97 MB, PASS.

Rate: 117.4 GB/s effective on 24 MB (launch-dominated) and 467.8 GB/s effective on 97 MB, which is in line with IQ2_XXS's
437.5.  IQ2_XS is 24 GB of the 2.25bpw set and IQ2_XXS 49 GB, so the two largest expert formats are now ported.
2026-10-23 18:50:26 +02:00
Niko1221 1d21a0f60a metal: the tiled IQ2_XXS kernel - 437 GB/s effective in the engine's 8-token window, 5x the one-token form
metal/tests/dsv41/iq2xxs_t8_parity.mm decodes the weights ONCE per 8-token window and applies them to eight accumulators, which is
how the engine runs decode (DSpark's draft/verify rounds are 4-5 tokens, the prompt window is 8).  Same tables, same decode as
the verified one-token kernel, and the reference checks it: relative L2 2.91e-07 on sampled rows, PASS.

The number that matters: 437.52 GB/s effective (54.69 GB/s of raw weight traffic) against 87.71 GB/s for the warp-per-row form.
At 2.33 GB of expert reads a token that is ~5.3 ms of expert time, i.e. ~188 tok/s of headroom - the first measurement that says
the 50 tok/s target is reachable, and it says the expert path must be the T-window form from the start, as the repo's moe_mm is.
2026-10-23 18:49:12 +02:00
Niko1221 9c1e61a6f3 metal: the IQ2_XXS expert GEMV ported - 87.7 GB/s, and why it cannot go faster as a warp-per-row kernel
metal/tests/dsv41/iq2xxs_gemv_parity.mm decodes IQ2_XXS exactly as ggml does (block 66 B, db = d*(0.5 + (aux32[1] >> 28))*0.25,
grid = iq2xxs_grid[aux8[l]], signs = ksigns_iq2xs[(aux32[1] >> 7l) & 127]) and dots it with the activations.  Measured against a
CPU reference that uses the same tables: 65,536 rows x 5,120 k, relative L2 2.88e-07, PASS, 87.71 GB/s.

Two findings that shape the expert path:

* the group headers advance 4 bytes and read 8, so an 8-byte uint2 load is misaligned on odd groups.  Both vector-load variants
  failed (L2 1.38 and 0.985) and were slower; byte assembly is the correct access pattern, as in CUDA's own decoder.
* 87.7 GB/s is 4.8x slower than the BF16 GEMV's 425 GB/s for the same traffic, because the grid decode costs per byte.  At 2.33 GB
  of expert reads a token that caps decode near 38 tok/s - below the 50 target - so the expert path must be tiled (one weight read
  feeding several tokens, as the repo's moe_mm does) rather than one row per warp.

That is a measured design constraint now, not an assumption.
2026-10-23 18:48:35 +02:00
Niko1221 45faf57702 metal: ggml's i-quant grid tables in MSL, generated from the pinned header
metal/src/engine/msl/ggml_grids.h carries kmask_iq2xs[8], ksigns_iq2xs[128], iq2xxs_grid[256], iq2xs_grid[512], iq2s_grid[1024],
iq3xxs_grid[256] and iq3s_grid[512] as MSL constant arrays.  metal/tools/gen_grids.sh produces them from
third_party/ggml/ggml-common.h (3cf03257f, GGML_TABLE_BEGIN/END blocks), so the Metal expert kernels use bit-identical tables
to CUDA instead of a hand copy - the same discipline that caught the half-rounded-scale error in the Q8 kernel.

Every expert format in the 2.25bpw set (IQ2_XXS 49 GB, IQ2_XS 24 GB, IQ2_S 16 GB, IQ3_XXS) is grid-based, so nothing in the
expert path could be written without these.
2026-10-23 18:45:35 +02:00
Niko1221 680724b87a metal: the Q8_0 weight GEMV ported - 173 GB/s, and the reference has to use the file's half-rounded scale
metal/tests/dsv41/q8_gemv_parity.mm: Q8_0 blocks (32 int8 + one f16 scale) times F32 activations, one warp a row, lane-strided
blocks.  16,384 rows x 5,120 k, relative L2 8.11e-08, PASS, 173.52 GB/s and 326.62 GFLOP/s (0.514 ms for 89 MB).

The first run measured 1.85e-04 and looked like a kernel bug; it was the CPU reference using the exact float scale while the
kernel reads the half-rounded scale from the file.  Matching the rounding took it to 8e-08 - the same class of drift that would
quietly cost quality if the port did not reproduce the file's arithmetic exactly.

Three kernels are now verified with reproducible parity tests: the Q8 activation quantiser, the BF16 GEMV and this one.
2026-10-23 18:45:00 +02:00
Niko1221 a445418e5e metal: the BF16 GEMV ported and measured - 83 GB/s warp-per-row, and why the expert path cannot stop there
metal/tests/dsv41/bf16_mv_parity.mm compiles the MSL version of the BF16 matrix-vector product (ds4_gguf.cu:1043's
mx_mv_kernel, single job: one warp a row, lane-strided k, warp reduction), dispatches it on the GPU and compares against a
double-precision reference: 2048 rows x 5120 k, relative L2 1.80e-07, PASS.

It also prints throughput: 83.19 GB/s and 83.2 GFLOP/s, 0.252 ms for 21 MB of BF16 weights.  That is a naive kernel (no
threadgroup tiling) against a GPU that streams 579 GB/s in rt_bench, so 7x of headroom remains - and the number matters:
2.33 GB of expert reads a token at 83 GB/s caps decode near 36 tok/s, which is exactly why the expert path has to use the
tiled multi-token kernels (the repo's gemv/moe_mm family) rather than a warp-per-row matvec.

A second measurement lesson: per-row relative error of ~1e-3 on a 5120-term float dot is expected cancellation, not a bug;
the standard measure is relative L2 over the result, which is what both parity tests now use.
2026-10-23 18:43:21 +02:00
Niko1221 baf261deca metal: the verified dsv41 activation quantiser, in the engine's kernel tree
metal/src/engine/msl/ds4_quant.metal carries the Q8 quantiser that metal/tests/dsv41/q8_quant_parity.mm proved bit-exact (4096
blocks, 131,072 values, 0 differences).  It is the first dsv41 kernel in the engine's own MSL directory, next to the Qwen
engine's mtp/ple kernels, so the runtime loader will find it by name when the launch is wired.

The comment records the driver quirk that cost the debugging: threadgroup_position_in_grid arrived as 0 in every threadgroup
on this Mac, so the block index comes from thread_position_in_grid / 32.
2026-10-23 18:41:43 +02:00
Niko1221 6bfcfa777f metal: the first dsv41 kernel ported and bit-exact - the Q8 activation quantiser
metal/tests/dsv41/q8_quant_parity.mm compiles the MSL version of q8_quant_kernel (ds4_gguf.cu:201) at run time, dispatches it
on the GPU and compares every int8 against the same arithmetic on the CPU.  4096 blocks, 131,072 values: 0 differ, worst
scale error 4.6e-04 (half rounding).  PASS.

Two bugs found on the way, both in the test harness rather than the kernel: indexing with thread_position_in_grid where the
threadgroup id was meant, and passing ldx = 1 where the stride between blocks of 32 values is 32 (which made block b read a
window starting at b instead of 32b - the giveaway was block 1 reproducing block 0's values shifted by one).

This is the pattern every remaining kernel follows: an MSL function, a pipeline from it, the args as buffers, one dispatch,
and a CPU reference in the same program.
2026-10-23 18:39:27 +02:00
Niko1221 ca903c124a metal: a launch with no Metal dispatch names its own file and line
strata_ds4_launch is a macro that passes __FILE__ and __LINE__ to strata_ds4_launch_at, so the abort message reads
'src/dsv41/ds4_iq.cu:1234' rather than a generic line.  Each run of the engine is then an exact work item instead of a guess
about which of the 146 launches is next, and the message is machine-readable from a log.  Build rc=0, strata_ds4 = 1,412,984 B.
2026-10-23 18:37:31 +02:00
Niko1221 883d0000a7 metal: the dsv41 engine links and runs on the Mac
Two things made this possible:

* the launch syntax.  146 kernel launches (119 in the sources, 27 in the vendor tree) are now
  strata_ds4_launch(kernel, grid, block, shared, stream, args...), a call a C++ compiler can parse, with a macro that forces
  the global name past argument-dependent lookup and a body that stops the run naming the kernel until a Metal dispatch exists.
  Every source then compiles as C++ through include/metal_compat - .cu files included, since the Metal project has no CUDA
  language and was silently skipping them.
* the last CUDA names the kernel files needed: __grid_constant__, cudaDataType, __umulhi/__mulhi, the bitwise atomics,
  __halves2half2 / __half2half2 / __low2half / __high2half / __low2float / __high2float / __hadd2 / __hmul2 / __hfma2 /
  __hneg2, the occupancy query and the stream-capture status.

The EXL3 legacy path (v1.1.8's kernels, NVIDIA before sm_80) is defined in metal/src/engine/exl3_legacy_stub.cpp: four of its
seven entry points abort with their name, three answer as the declarations require.  The 2.25bpw text model does not use EXL3.

Result: build rc=0, 0 undefined symbols, strata_ds4 = 1,410,552 bytes, Mach-O arm64, and it runs (its own usage text).  Next
is the run-time loop: a pack reports the first kernel it needs, implement it in MSL, repeat.
2026-10-23 18:35:00 +02:00
Niko1221 d692839177 metal: a strata_ds4 target, and what it proves
The DeepSeek host sources now build inside the Metal project as their own executable, with the .cu files compiled as C++ through
include/metal_compat.  ninja reaches step 23/35 and ds4_model.cu (8,232 lines, host only) builds; the first failure is a <<<>>>
launch in ds4_gguf.cu, which is the port's real boundary.

The measured size of the remaining work: 101 kernel launches to convert to Metal dispatches and 78 kernel bodies to write in MSL
(text only; the vision tower adds 21 and is optional).  ds4_model.cu has zero of both.

Two gotchas from building it: run_locked.sh returns exit code 69 for builds that are fine (run ninja directly), and the Xcode
licence line on stderr is noise - the toolchain compiles without accepting it.  Run with -j8 directly, the host sources compile in
about a minute.
2026-10-23 18:26:03 +02:00
Niko1221 aaf3aa3a7c metal: the last twelve CUDA names (dim3, funnel shift, transcendentals, ldg/ldcv, shfl_up) - no undeclared name left
A full-list compile (-ferror-limit=0) found 350 errors behind the 20-error display cap, and they collapsed into twelve missing
families.  With dim3, __threadfence_system, __funnelshift_r/l, rsqrtf, __expf/__logf/__sinf/__cosf, sincosf, __ldg, __ldcv,
__float2int_* and __shfl_up_sync in cuda_device_builtins.h, plus the C math names in scope, the counts are
ds4_iq 52, ds4_gguf 42, ds4_kernels 132, ds4_vision 27 (was 66/67/185/32) AND the distinct-names list is now EMPTY: what is
left is not a missing name but device construct - the <<<grid, block>>> launch syntax and shared-memory declarations that a
C++ compiler cannot parse as written.

That is the boundary of the host-compile audit, which was its purpose: the API surface is complete, and the remaining work is
the MSL implementation itself.
2026-10-23 18:14:32 +02:00
Niko1221 dc61a065e3 metal: finish the CUDA API surface the token path names, enumerated by the compiler
Twenty rounds of 'compile ds4_iq.cu as C++ and fix the first name it cannot find' produced the complete list, and it is small:
__byte_perm and the bit/int intrinsics, the SIMD video instructions with their exact per-lane semantics (__vsub4,
__vsubss4, __vadd4, __vaddss4, __vcmpeq4, __vcmpgtu4, __dp4a), the rn float helpers, integer min/max, __align__, the
vector types beyond float2/float4, the CUDA device built-ins for the host compile (threadIdx/blockIdx/__syncthreads/__shfl_*
- labelled as host-only, nothing there is a kernel), and wmma in CUDA 12's tag style whose every call aborts naming the
simdgroup-matrix equivalent.

ds4_model.cu stays at 0 errors.  The four kernel sources are at the compiler's 20-error display cap; what is left behind it
is mma_sync's signature and single scalars (__float2int_rn), not structure.  Next is the MSL work itself.
2026-10-23 18:13:33 +02:00
Niko1221 e46b149ae5 metal: fp8 conversion modes, the round-to-nearest float helpers, and an mma.h placeholder
ds4_numerics.cuh names __NV_E4M3/__NV_SATFINITE and __half_raw, ds4_q6k.cuh names __fmul_rn/__ushort_as_half, and both kernel
files include mma.h: cuda_bf16.h now carries the fp8 enums and includes cuda_fp16.h (for __half_raw), cuda_fp16.h carries the
five rn helpers and __ushort_as_half, and mma.h says what it is (the wmma surface does not exist on Metal; the token path
does not use it, and an instantiation fails loudly).

State after this: ds4_model.cu 0 errors; the four kernel files are at the compiler's 20-error limit with a shorter list each -
the remaining class is the device code itself (block decoders, shuffles, shared memory), which is the MSL work.
2026-10-23 18:10:48 +02:00
Niko1221 18c6075562 metal: the half-precision and vector-type surface (cuda_fp16.h, vector_types.h, cuda_fp8.h)
The kernel sources' single remaining error was cuda_fp16.h not existing; with it, vector_types.h and cuda_fp8.h in place they
get past the wall and show their real errors - ds4_iq 7, ds4_gguf 7, ds4_kernels 20, ds4_vision 20 - which is now the work
queue for the Metal port.  ds4_model.cu stays at 0.

Both copies (this branch and the box) carry the box's version of cuda_fp16.h; an earlier local edit had failed on a path and
this commit syncs them.
2026-10-23 18:08:54 +02:00
Niko1221 a798d03858 metal: the dsv41 host side compiles - ds4_model.cu goes from 20 errors to 0
The last layer of the compat surface: the CUDA function-space qualifiers (__host__/__device__/__global__/__shared__/
__constant__/__forceinline__/__launch_bounds__), cpu_set_t's CPU_ZERO/CPU_SET for the macOS branch of ds4_os.hpp,
CUBLAS_GEMM_DEFAULT_TENSOR_OP, a cudaMemcpy2D that reports failure rather than copying nothing, and a cublasGemmEx that takes
any argument list (the engine passes cuBLAS's full 19-argument spelling) and aborts naming kernels/dense_mm.metal.

With that, src/dsv41/ds4_model.cu - 8,232 lines, host only - compiles against metal/include/metal_compat on the M5 Max.  The
four kernel sources are one error each, and the kernel work itself (MSL bodies for 27 + 76 + 33 device functions, plus the
dense GEMM and a pitched copy at the runtime) is what remains before a first token.
2026-10-23 18:07:34 +02:00
Niko1221 bb60de0e8e metal: ds4_os.hpp takes the macOS shim at the top of the file, not inside the Windows block
The include had landed between '#ifndef NOMINMAX' and '#include <windows.h>', so on a Mac it was never reached - that is why
the audit still showed every Linux name undeclared.  Moved after '#pragma once': ds4_model.cu goes from 20 to 15 errors.

The 15 left are four CPU_ZERO/CPU_SET uses (macOS has no cpu_set_t macros) and the __host__/__device__ qualifiers that
cuda_runtime.h does not define yet; both are a few lines and are the next step, then the Linux surface of the engine's
storage helpers is done.
2026-10-23 18:05:41 +02:00
Niko1221 910af91838 metal: a macOS stand-in for the Linux OS surface ds4_os.hpp uses (half done - not yet effective)
ds4_os_apple.h maps O_DIRECT (-> 0, F_NOCACHE does the work), posix_fadvise (-> 0), mincore (-> ENOSYS, 'unknown' is the
answer the header's contract already allows), MADV_HUGEPAGE and SYS_mbind (no-ops) and the CPU-affinity calls (no-ops on a
kernel that schedules on its own), and ds4_os.hpp includes it at the top under __APPLE__.

State: included, but ds4_model.cu still reports the same 20 errors at the same uses, so something in the include order is
not taking effect yet - the next session's first job is one -E on ds4_os.hpp to see which definitions win.  Not claiming it
works.
2026-10-23 18:04:59 +02:00
Niko1221 bcb2133a28 metal: the dsv41 core's CUDA surface, part one (bf16/fp8 conversions, the cuBLAS names, the compile audit)
Compiling src/dsv41/*.cu against metal/include/metal_compat as C++ is how the DeepSeek-on-Metal port is scoped, and it is
much smaller than assumed: ds4_model.cu is 8,232 lines of *host* code with zero device markers, and the six non-kernel
sources already compile.  What was missing at the compat layer:

* cuda_bf16.h - __nv_bfloat16/__nv_bfloat162/__nv_fp8_storage_t and the four conversions the engine uses
  (__float2bfloat16_rn, __bfloat16_as_ushort, __nv_cvt_float_to_fp8, __nv_cvt_fp8_to_halfraw), E4M3 with round-to-nearest.
* cublas_v2.h - the nine GEMM calls (7 cublasGemmEx, 2 cublasSgemm) and their enums.  The dense GEMM itself is NOT wired:
  it aborts with a message naming kernels/dense_mm.metal (which exists and passes its parity test) rather than returning a
  silently wrong result.

After these, ds4_model.cu's remaining 20 errors are all in ds4_os.hpp - the Linux-only OS surface (O_DIRECT,
posix_fadvise, mincore, MADV_HUGEPAGE, SYS_mbind, cpu_set_t/sched_setaffinity) - which needs a __APPLE__ branch.
2026-10-23 18:04:03 +02:00
Niko1221 f0f056b9bb dsv41 mac: the CPU-only build works on arm64 (x86 kernels guarded, the AVX2 row entry points answer no)
strata_kernels_cpu compiled expert.cpp / q2_avx2.cpp / iq_avx*.cpp with hard-coded -mavx512f / -mavx2, so on an arm64 host the
build died with 'unsupported option -mavx512f for target arm64-apple-darwin27.2.0'.  STRATA_CPU_KERNELS_X86 now decides:
on x86 the same sources and ISA flags as before, elsewhere the portable files plus src/kernels/cpu/iq256_portable.cpp, which
defines iq256_supported() = false and iq256_rows() = abort (callers check the first), so ds4_gg_cpu.cpp keeps ggml-cpu's
rows - which have their own NEON kernels on Apple Silicon.

Verified on the M5 Max: configure rc=0, build rc=0, ds4_gg_bench runs IQ3_XXS at 83.9 GB/s through ggml-cpu's rows (the
x86 EPYC does 60.5), 0 of 1,567,344 values differ in bits against ggml and 7.14e-09 against a double reference.  The x86 path
is untouched: the guard only selects which sources join the library.
2026-10-23 17:53:59 +02:00
258 changed files with 20662 additions and 795 deletions

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ body:
attributes:
label: Strata version
description: "In the app: Settings > About. In the Terminal: `strata --version`. From source: the About tab of the web page, or `engine` in `GET /v1/status`, or the git commit."
placeholder: "Strata 0.2.7 · engine 0.1.52"
placeholder: "Strata 0.2.8 · engine 0.1.54"
validations:
required: true
- type: input
+6
View File
@@ -89,3 +89,9 @@ node_modules/
# screenshots and checkpoints from the Mac rebuild live outside git (StrataSplit\evidence-archive)
/apps/host/docs/evidence/
/apps/host/docs/checkpoints/
# DGX Spark build outputs and local venv (docs/DGX_SPARK_LINUX.md)
/out-spark/
/build-spark/
/build-vision-spark/
/.venv-build/
+2 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md
Version 0.2.7, engine 0.1.52. The public model families are DeepSeekV4.1-Flash-1017,
Version 0.2.8, engine 0.1.54. The public model families are DeepSeekV4.1-Flash-1017,
Qwen3.8-Flash-Next and Qwen3.6-35B-A3B. DeepSeek V4.1-Flash runs in the app and in the Terminal
(`strata launch --family deepseek41 --model ist-das-2.00bpw`; sets in
[docs/MODELS.md](docs/MODELS.md#deepseek-v41-flash)). Read **[docs/AI_REFERENCE.md](docs/AI_REFERENCE.md)** for model availability, APIs, hardware limits and
@@ -15,7 +15,7 @@ Strata has three parts:
- **The app** (`apps/desktop`, the Tauri shell; `apps/host`, the Node host and the web UI). It is for people who do
not want a terminal. One installer per system ([latest release](https://github.com/Niko1221/Strata/releases/latest)):
`Strata-0.2.7-windows-x64-setup.exe`, `Strata-0.2.7-linux-x86_64.AppImage`, `Strata-0.2.7-macos-arm64.dmg`.
`Strata-0.2.8-windows-x64-setup.exe`, `Strata-0.2.8-linux-x86_64.AppImage`, `Strata-0.2.8-macos-arm64.dmg`.
The Windows and Linux installers carry no engine: the app downloads the engine for the graphics card on first
start, or installs a file the user already has. The Mac dmg keeps its small Metal engine inside.
- **The Terminal** (`strata.py`, `strata_cli/`, `strata_control/`): the `strata` command. It sets up a model, serves
+58 -2
View File
@@ -8,7 +8,7 @@
# below refuses anything older: silently building for a pre-Ampere arch would produce a binary that runs
# and is wrong about its own performance, which is the worst of both outcomes.
cmake_minimum_required(VERSION 3.24)
project(strata VERSION 0.1.52 LANGUAGES CXX)
project(strata VERSION 0.1.54 LANGUAGES CXX)
# EXPERIMENTAL: the Intel Arc engine, the SYCL port in sycl/ (PR #423; icpx + oneMKL, docs/INTEL_ARC.md). It is its
# own project that takes the unported sources from src/; with this on, nothing below is configured.
option(STRATA_ENABLE_SYCL "EXPERIMENTAL: build the Intel Arc (SYCL) engine from sycl/ - docs/INTEL_ARC.md" OFF)
@@ -92,6 +92,31 @@ if(STRATA_HIP_GFX906)
endif()
option(STRATA_ENABLE_HIP "Build the HIP targets for wave32 AMD GPUs (gfx1100, gfx1201; gfx1012/gfx1030 unvalidated)" OFF)
option(STRATA_PREFILL_MMQ "Build the optional GGML MMQ prefill path for HIP" OFF)
# The rows one verify / batch window holds (include/strata/kernels/window_limits.hpp): 8, the 0.2.7 window, or 16 (four pipelined
# groups of 16 slots = 64 slots on a four-card split, docs/BATCHING.md). Only the CUDA and HIP engines are built from
# these sources with a wide window; the SYCL and Metal builds never read this variable and keep 8. A window of up to
# 8 rows runs the kernels it always ran, whichever is chosen (the wide instantiations are extra code). "auto" = 16 for
# the HIP engines (a HIP code object loads on first use, so the extra kernels cost nothing until a wide window forms)
# and 8 for CUDA, which loads every kernel at start (CUDA_MODULE_LOADING=EAGER: the wide kernels would take VRAM from
# the expert cache of every card); -DSTRATA_WINDOW_MAX_T=16 builds the wide CUDA engine.
set(STRATA_WINDOW_MAX_T "auto" CACHE STRING "rows of a verify / batch window: 8, 16 or auto (HIP 16, CUDA 8)")
set_property(CACHE STRATA_WINDOW_MAX_T PROPERTY STRINGS auto 8 16)
if(NOT STRATA_WINDOW_MAX_T STREQUAL "auto" AND NOT STRATA_WINDOW_MAX_T STREQUAL "8" AND NOT STRATA_WINDOW_MAX_T STREQUAL "16")
message(FATAL_ERROR "STRATA_WINDOW_MAX_T must be auto, 8 or 16 (got '${STRATA_WINDOW_MAX_T}')")
endif()
if(STRATA_ENABLE_HIP OR STRATA_ENABLE_CUDA)
if(STRATA_WINDOW_MAX_T STREQUAL "auto")
if(STRATA_ENABLE_HIP)
set(_strata_window_max_t 16)
else()
set(_strata_window_max_t 8)
endif()
else()
set(_strata_window_max_t ${STRATA_WINDOW_MAX_T})
endif()
add_compile_definitions(STRATA_VERIFY_MAX_T=${_strata_window_max_t})
message(STATUS "Strata: verify / batch windows hold up to ${_strata_window_max_t} rows")
endif()
# MMQ prompt kernels for Q4_K / Q5_K / Q5_1 / Q6_K (Unsloth's UD-Q4/Q5/Q6_K_XL experts; on HIP also the dense GGUF
# projections, STRATA_DENSE_MMQ). On by default since MODEL-UPDATE Phase 1: on CUDA they are their own library
# (strata_kq, loaded only for a model with these formats: the engine loads every kernel of its own binary at start,
@@ -131,6 +156,11 @@ 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()
if(NOT STRATA_CPU_X86)
# arm64 Linux (DGX Spark): much of the suite drives the x86 CPU kernels, which this build does not have. A default, not a
# prohibition: -DSTRATA_BUILD_TESTS=ON still builds what builds (classifying the tests by capability is a follow-up).
set(_strata_tests_default OFF)
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)
@@ -1628,6 +1658,11 @@ if(STRATA_ENABLE_CUDA OR STRATA_ENABLE_HIP)
target_include_directories(ple_residency_test PRIVATE include)
add_test(NAME ple_residency_test COMMAND ple_residency_test)
# #4474: the automatic expert cache's slot rule (the late allocation priced); header-only, needs nothing
add_executable(auto_cache_test src/core/auto_cache_test.cpp)
target_include_directories(auto_cache_test PRIVATE include)
add_test(NAME auto_cache_test COMMAND auto_cache_test)
# 0.2.7 (#3911): the qwen35 host sampler's penalties on a short (draft-vocab) logit row; header-only, CPU
add_executable(qwen35_sampling_test src/qwen35/sampling_test.cpp)
target_include_directories(qwen35_sampling_test PRIVATE include)
@@ -1690,6 +1725,9 @@ if(STRATA_BUILD_TESTS)
add_executable(arch_defaults_test src/core/arch_defaults_test.cpp src/core/arch_defaults.cpp)
target_include_directories(arch_defaults_test PRIVATE include)
add_test(NAME arch_defaults_test COMMAND arch_defaults_test)
add_executable(nv_defaults_test src/core/nv_defaults_test.cpp src/core/nv_defaults.cpp) # the sm_121 (GB10) table and its two off switches
target_include_directories(nv_defaults_test PRIVATE include)
add_test(NAME nv_defaults_test COMMAND nv_defaults_test)
add_executable(kva_test src/prefill/kva_test.cpp) # KVA: the switch, the automatic tail, the reasons a read is turned away
target_include_directories(kva_test PRIVATE include)
add_test(NAME kva_test COMMAND kva_test)
@@ -1728,6 +1766,10 @@ if(STRATA_BUILD_TESTS OR STRATA_BUILD_CONVERSATION_TESTS)
add_executable(adapt_decay_test src/program/adapt_decay_test.cpp)
target_include_directories(adapt_decay_test PRIVATE include)
add_test(NAME adapt_decay_test COMMAND adapt_decay_test)
# the window limits (8 rows, or 16 in a wide CUDA / HIP build) and the pipelined-window probe (header-only, CPU)
add_executable(window_limits_test src/core/window_limits_test.cpp)
target_include_directories(window_limits_test PRIVATE include)
add_test(NAME window_limits_test COMMAND window_limits_test)
# which window-size graph is freed when the next one does not fit (#3702; header-only, CPU)
add_executable(window_evict_test src/core/window_evict_test.cpp)
target_include_directories(window_evict_test PRIVATE include)
@@ -1844,7 +1886,14 @@ if(STRATA_NATIVE_EXPERTS)
set(GGML_BACKEND_DL OFF CACHE BOOL "" FORCE)
set(GGML_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GGML_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
if(STRATA_PORTABLE)
if(STRATA_PORTABLE AND NOT STRATA_CPU_X86)
# arm64 (DGX Spark: Cortex-X925 + A725): ggml-cpu for any Armv8.2 CPU with dot product, FP16 and int8 matmul instead of the build
# host's, so the engine built here does not depend on which core the compiler ran on. Without it GGML_NATIVE OFF would leave
# ggml-cpu at plain armv8-a, and a native pack's CPU experts would lose the NEON dot products.
set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
set(STRATA_ARM64_ARCH "armv8.2-a+dotprod+fp16+i8mm" CACHE STRING "arm64: ggml-cpu's -march for a STRATA_PORTABLE build")
set(GGML_CPU_ARM_ARCH "${STRATA_ARM64_ARCH}" CACHE STRING "" FORCE)
elseif(STRATA_PORTABLE)
set(GGML_NATIVE OFF CACHE BOOL "" FORCE)
foreach(_f GGML_AVX GGML_AVX2 GGML_FMA GGML_F16C GGML_BMI2)
set(${_f} ON CACHE BOOL "" FORCE)
@@ -1897,6 +1946,10 @@ if(STRATA_NATIVE_EXPERTS)
src/kernels/cpu/kq_avx2.cpp src/kernels/cpu/kq_avx1.cpp src/kernels/cpu/q2_avx1.cpp
src/kernels/cpu/iq_avx1.cpp src/kernels/cpu/nvfp4_avx512.cpp src/kernels/cpu/nvfp4_avx2.cpp
src/kernels/cpu/tq_cpu.cpp src/kernels/cpu/tq_avx2.cpp src/kernels/cpu/tq_avx512.cpp)
else()
# the trellis dispatcher alone (the DeepSeek engine's CPU lane and Gyro): tq_cpu.cpp runs its scalar rows when the x86 units are
# not compiled (the Metal build does the same)
target_sources(strata_kernels_cpu PRIVATE src/kernels/cpu/tq_cpu.cpp)
endif()
target_include_directories(strata_kernels_cpu PRIVATE third_party/ggml)
if(MSVC)
@@ -1949,6 +2002,8 @@ if(STRATA_NATIVE_EXPERTS)
target_link_libraries(dequant_q5_1_test PRIVATE strata_artifact ggml-base)
add_test(NAME dequant_q5_1_test COMMAND dequant_q5_1_test)
endif()
# the x86 kernels' parity programs and benches (to the matching endif below): not on arm64, which has none of those kernels
if(STRATA_CPU_X86)
# the router lookahead's dot on each rung this CPU has (AVX2, the older CPUs' AVX1) against a double reference
add_executable(router_dot_parity src/kernels/cpu/router_dot_parity.cpp)
target_link_libraries(router_dot_parity PRIVATE strata_kernels_cpu)
@@ -2009,6 +2064,7 @@ if(STRATA_NATIVE_EXPERTS)
add_test(NAME q8k_quant_parity COMMAND q8k_quant_parity)
add_test(NAME q8k_quant_parity_avx COMMAND ${CMAKE_COMMAND} -E env STRATA_FORCE_ISA=avx $<TARGET_FILE:q8k_quant_parity>)
endif()
endif() # STRATA_CPU_X86
set(_strata_mmq_types q2_0 iq2_xxs iq2_xs iq2_s iq3_xxs iq3_s iq4_nl iq4_xs q8_0)
# APR: the agention llama.cpp fork has MMQ instances for its trellis expert types (f62fdead0); any other ggml
# tree leaves them to the FP16 prompt path
+1 -1
View File
@@ -95,7 +95,7 @@ Unsicher, was passt? [Wähle ein Modell für deinen Computer](docs/MODELS.md#pic
Installiere die App, wähle ein Modell und leg mit dem Chat los. Es gibt einen Installer pro System. Beim ersten Start lädt die App die Engine für deine Grafikkarte herunter. [Hilfe zum Installer](docs/INSTALL.md#the-app).
<sub>Dateien: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>Dateien: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata-Desktop-App mit Chat und Live-Codevorschau nebeneinander">
+1 -1
View File
@@ -95,7 +95,7 @@ Medido con el conjunto de 2.00 bpw en Strata 0.2.5. «Salida» es la velocidad d
Instala la aplicación, elige un modelo y empieza a chatear. Hay un instalador por sistema. En el primer arranque, la aplicación descarga el motor para tu tarjeta gráfica. [Ayuda con el instalador](docs/INSTALL.md#the-app).
<sub>Archivos: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>Archivos: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplicación de escritorio de Strata con un chat y una vista previa del código en vivo, una junto a otra">
+1 -1
View File
@@ -95,7 +95,7 @@ Vous ne savez pas ce qui tient ? [Choisir un modèle pour votre ordinateur](docs
Installez l'application, choisissez un modèle et commencez à discuter. Il y a un seul installateur par système. Au premier lancement, l'application télécharge le moteur de votre carte graphique. [Aide pour l'installateur](docs/INSTALL.md#the-app).
<sub>Fichiers: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>Fichiers: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Application de bureau Strata avec une discussion et un aperçu du code en direct côte à côte">
+1 -1
View File
@@ -95,7 +95,7 @@ Strata 0.2.5 で 2.00 bpw のセットを使って測定しました。「出力
アプリをインストールし、モデルを選んで、チャットを始めましょう。インストーラーはシステムごとに1つです。初回起動時に、アプリがお使いのグラフィックスカード用のエンジンをダウンロードします。[インストーラーのヘルプ](docs/INSTALL.md#the-app)。
<sub>ファイル: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>ファイル: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="チャットとライブのコードプレビューを並べて表示した Strata デスクトップアプリ">
+4 -3
View File
@@ -63,8 +63,8 @@ disk space.
| 2.00 bpw | 257 GB | about 265 GB |
| 2.25 bpw | 274 GB | about 280 GB |
Runs on NVIDIA RTX 30, 40 and 50 series (Windows and Linux), AMD Radeon AI PRO R9700 (Linux), and AMD Ryzen AI Max — Strix Halo
(Windows). Setup downloads the files and the DeepSeek engine, then starts the model.
Runs on NVIDIA RTX 30, 40 and 50 series (Windows and Linux), AMD Radeon AI PRO R9700 (Linux), AMD Ryzen AI Max — Strix Halo
(Windows), and Apple Silicon Macs with 64 GB or more, text only ([Mac](docs/DEEPSEEK_MAC.md)). Setup downloads the files and the DeepSeek engine, then starts the model.
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan
@@ -99,7 +99,7 @@ Not sure what fits? [Choose a model for your computer](docs/MODELS.md#pick-by-me
Install the app, choose a model, and start chatting. There is one installer for each system. On first start the app
downloads the engine for your graphics card. [Help with the installer](docs/INSTALL.md#the-app).
<sub>Files: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>Files: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata desktop app with a chat and live code preview side by side">
@@ -158,6 +158,7 @@ Follow the prompts to download and start a model. [Terminal guide](docs/TERMINAL
| **Intel Arc · Linux** | [Intel setup](docs/INTEL_ARC.md) |
| **Apple Silicon · M1 or newer** | [Mac setup](docs/MAC.md) |
| **AMD Ryzen AI Max · Strix Halo** | [Strix Halo setup](docs/STRIX_HALO.md) |
| **NVIDIA DGX Spark · Linux** | [DGX Spark setup](docs/DGX_SPARK_LINUX.md) |
<a id="using-it"></a>
+1 -1
View File
@@ -99,7 +99,7 @@ Nie wiesz, co się zmieści? [Wybierz model do swojego komputera](docs/MODELS.md
Zainstaluj aplikację, wybierz model i zacznij rozmawiać. Dla każdego systemu jest jeden instalator. Przy pierwszym
uruchomieniu aplikacja pobiera silnik dla twojej karty graficznej. [Pomoc z instalatorem](docs/INSTALL.md#the-app).
<sub>Pliki: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>Pliki: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplikacja Strata na komputer: czat i podgląd kodu na żywo obok siebie">
+1 -1
View File
@@ -95,7 +95,7 @@ Não sabe o que cabe? [Escolha um modelo para o seu computador](docs/MODELS.md#p
Instale o aplicativo, escolha um modelo e comece a conversar. Há um instalador por sistema. Na primeira vez que abre, o aplicativo baixa o motor da sua placa de vídeo. [Ajuda com o instalador](docs/INSTALL.md#the-app).
<sub>Arquivos: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>Arquivos: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Aplicativo de desktop do Strata com um chat e uma prévia do código ao vivo lado a lado">
+1 -1
View File
@@ -95,7 +95,7 @@ strata launch --family deepseek41 --model ist-das-2.00bpw --plan
安装应用,选择模型,就可以开始聊天。每个系统只有一个安装程序。首次启动时,应用会下载适合你显卡的引擎。[安装程序帮助](docs/INSTALL.md#the-app)。
<sub>文件: `Strata-0.2.7-windows-x64-setup.exe` · `Strata-0.2.7-linux-x86_64.AppImage` · `Strata-0.2.7-macos-arm64.dmg`</sub>
<sub>文件: `Strata-0.2.8-windows-x64-setup.exe` · `Strata-0.2.8-linux-x86_64.AppImage` · `Strata-0.2.8-macos-arm64.dmg`</sub>
<p align="center">
<img src="docs/media/app-chat.png" width="900" alt="Strata 桌面应用,聊天和实时代码预览并排显示">
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata-desktop",
"productName": "Strata",
"version": "0.2.7",
"version": "0.2.8",
"private": true,
"description": "Strata Desktop - local AI, tuned to one model.",
"scripts": {
+1 -1
View File
@@ -3625,7 +3625,7 @@ checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7"
[[package]]
name = "strata-desktop"
version = "0.2.7"
version = "0.2.8"
dependencies = [
"gtk",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "strata-desktop"
version = "0.2.7"
version = "0.2.8"
description = "Strata Desktop — local AI, tuned to one model."
edition = "2021"
rust-version = "1.77"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "strata",
"version": "0.2.7",
"version": "0.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "strata",
"version": "0.2.7",
"version": "0.2.8",
"dependencies": {
"html-to-image": "^1.11.13",
"katex": "^0.19.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "strata",
"private": true,
"version": "0.2.7",
"version": "0.2.8",
"type": "module",
"description": "Strata \u2014 local AI, tuned to one model. Web shell + engine host.",
"scripts": {
+17 -7
View File
@@ -62,6 +62,18 @@ export function parsePcieThroughput(raw) {
}
async function read(path) { try {return (await readFile(path,'utf8')).trim();} catch {return '';} }
async function list(path) {try{return await readdir(path);}catch{return [];}}
/** A server's remote-console chip (ASPEED), a virtual display or a framebuffer is not a graphics card: no maker we know and no memory of its own. */
export function isComputeAdapter(vendor,vramBytes) { return vendor!=='unknown'||vramBytes>0; }
/**
* The temperature of an amdgpu card from its hwmon inputs [{label, milli}]: the edge sensor (what nvtop and rocm-smi show), else the
* junction, else the first one that has a plausible value. Cards such as the Instinct line have no edge sensor. 0 is no reading.
*/
export function pickGpuTemperature(inputs) {
const ok=inputs.filter(i=>i.milli!==null&&i.milli>0&&i.milli<=150000);
const first=re=>ok.find(i=>re.test(i.label??''));
const chosen=first(/edge|^gpu/i)??ok.find(i=>!i.label)??first(/junction|hotspot/i)??ok.find(i=>!/mem/i.test(i.label??''));
return chosen?chosen.milli/1000:null;
}
export async function detectDrmGpus() {
const result=[];
for(const card of (await list('/sys/class/drm')).filter(n=>/^card\d+$/.test(n))) {
@@ -71,6 +83,7 @@ export async function detectDrmGpus() {
const vendor={'0x1002':'amd','0x8086':'intel','0x10de':'nvidia'}[vendorId]??'unknown';
const name=await probe('lspci',['-s',busId]);
const bytes=number(await read(`${device}/mem_info_vram_total`));
if(!isComputeAdapter(vendor,bytes))continue;
result.push({id:`drm:${busId}`,index:result.length,busId,vendor,name:name.replace(/^[^ ]+\s+[^:]+:\s*/,'')||`${vendor.toUpperCase()} GPU`,vramGb:bytes===null?0:round(bytes/1024**3,1),unified:vendor==='intel'&&!(bytes>0),backend:'vulkan',sensorPath:device});
}
return result;
@@ -89,7 +102,7 @@ export async function readGpuTelemetry(gpus, { transfer: wantTransfer = true, ru
if(gpu.vendor==='nvidia')reading={...reading,pcieRxMbs:transfer.get(gpu.sensorIndex??gpu.index)??null};
if(gpu.luid&&!nvidia.has(gpu.id)) {
const counters=windowsGpuReading(gpu.luid);
if(counters)reading={...reading,utilizationPct:counters.utilizationPct,memoryUsedGb:round(counters.memoryUsedGb,2)};
if(counters)reading={...reading,utilizationPct:counters.utilizationPct,memoryUsedGb:round(counters.memoryUsedGb,2),temperatureC:counters.temperatureC??null};
}
if(gpu.sensorPath&&!nvidia.has(gpu.id)) {
let temperature=null,power=null,powerLimit=null,fanPwm=null,clockHz=null;
@@ -99,12 +112,9 @@ export async function readGpuTelemetry(gpus, { transfer: wantTransfer = true, ru
powerLimit ??= number(await read(`${folder}/power1_cap`));
fanPwm ??= number(await read(`${folder}/pwm1`)); // 0-255, the fan's duty as the amdgpu driver reports it
clockHz ??= number(await read(`${folder}/freq1_input`)); // the graphics clock in Hz
for(const input of inputs) {
const label=await read(`${folder}/${input.replace('_input','_label')}`);
if(label&&!/edge|gpu/i.test(label))continue;
const milli=number(await read(`${folder}/${input}`));
if(milli!==null&&milli>=0&&milli<=150000){temperature=milli/1000;break;}
}
const sensors=[];
for(const input of inputs)sensors.push({label:await read(`${folder}/${input.replace('_input','_label')}`),milli:number(await read(`${folder}/${input}`))});
temperature=pickGpuTemperature(sensors);
if(temperature!==null)break;
}
const utilization=number(await read(`${gpu.sensorPath}/gpu_busy_percent`)),bytes=number(await read(`${gpu.sensorPath}/mem_info_vram_used`));
+15 -2
View File
@@ -1,12 +1,25 @@
const MEASURES=['temperatureC','utilizationPct','memoryUsedGb','powerW','powerLimitW','pcieGen','pcieGenMax','pcieWidth','pcieRxMbs'];
/** The engine's numbers win; a number the engine leaves out comes from the host's own reading of the same card. */
export function mergeOwnReading(engine,own) {
if(!own)return engine;
const merged={...own,...Object.fromEntries(Object.entries(engine).filter(([,v])=>v!==null&&v!==undefined))};
const engineGave=MEASURES.some(k=>typeof engine[k]==='number');
return {...merged,source:engineGave?'engine':own.source};
}
/** Translate the original Strata /metrics dashboard without inventing missing sensors. */
export function originalDashboard(raw, hardware, fallback) {
const live=raw.live,engine=raw.engine,hw=raw.hardware??{},history=raw.history??{},cache=raw.conversation_cache,totals=raw.totals;
const finite=n=>typeof n==='number'&&Number.isFinite(n);
const gb=n=>finite(n)?n/1024**3:null;
const devices=hardware?.gpus??[];
// The host's own sensor readings (nvidia-smi, amdgpu sysfs, the Windows GPU counters) fill in what the engine does not report:
// an AMD card on Windows has no engine reader at all, and the engine never gives the fan or the clock (#3727).
const own=new Map((fallback?.hardware?.gpus??[]).map(r=>[r.id,r]));
const readings=(hw.gpus??(devices.length===1?[{index:devices[0].index,util:hw.gpu_util,mem_used:hw.gpu_mem_used,temp:hw.gpu_temp,power:hw.gpu_power,power_limit:hw.gpu_power_limit,pcie_gen:hw.gpu_pcie_gen,pcie_gen_max:hw.gpu_pcie_gen_max,pcie_width:hw.gpu_pcie_width,pcie_rx_mb:hw.gpu_pcie_rx_mb}]:[])).map(g=>{
const device=devices.find(d=>d.index===g.index);
return {id:device?.id??`${device?.vendor??'unknown'}:${g.index}`,source:'engine',sampledAt:new Date().toISOString(),temperatureC:g.temp??null,utilizationPct:g.util??null,memoryUsedGb:gb(g.mem_used),powerW:g.power??null,powerLimitW:g.power_limit??null,pcieGen:g.pcie_gen??null,pcieGenMax:g.pcie_gen_max??null,pcieWidth:g.pcie_width??null,pcieRxMbs:g.pcie_rx_mb??null};
const id=device?.id??`${device?.vendor??'unknown'}:${g.index}`;
return mergeOwnReading({id,source:'engine',sampledAt:new Date().toISOString(),temperatureC:g.temp??null,utilizationPct:g.util??null,memoryUsedGb:gb(g.mem_used),powerW:g.power??null,powerLimitW:g.power_limit??null,pcieGen:g.pcie_gen??null,pcieGenMax:g.pcie_gen_max??null,pcieWidth:g.pcie_width??null,pcieRxMbs:g.pcie_rx_mb??null},own.get(id));
});
const last=raw.requests?.[0],used=live.state!=='idle'&&live.state!=='unloaded'?(live.prompt_tokens??0)+(live.generated??0):(last?.prompt_tokens??0)+(last?.output_tokens??0);
const maximum=engine.max_context??fallback.context.maxTokens;
@@ -20,7 +33,7 @@ export function originalDashboard(raw, hardware, fallback) {
generation:{done:live.generated??null,total:live.max_tokens??null},
queue:{running:live.running??(['reading','generating'].includes(live.state)?1:0),waiting:live.queued??0},context:{usedTokens:used,maxTokens:maximum,pct:maxPct(used,maximum)},
expertCache:{used:engine.expert_slots??0,total:engine.experts??0,memoryGb:finite(engine.expert_cache_mib)?engine.expert_cache_mib/1024:null,source:finite(engine.expert_slots)?'engine':'none'},
hardware:{...fallback.hardware,gpus:readings.length?readings:fallback.hardware.gpus,gpuLoad:hw.gpu_util??null,vramUsedGb:gb(hw.gpu_mem_used),vramTotalGb:gb(hw.gpu_mem_total)??fallback.hardware.vramTotalGb,gpuTempC:hw.gpu_temp??null,powerW:hw.gpu_power??null,powerLimitW:hw.gpu_power_limit??null,pcieRxMbs:hw.gpu_pcie_rx_mb??null,cpuLoad:hw.cpu??fallback.hardware.cpuLoad,ramUsedGb:gb(hw.ram_used)??fallback.hardware.ramUsedGb,ramTotalGb:gb(hw.ram_total)??fallback.hardware.ramTotalGb,diskReadGbs:finite(hw.disk_read_mb)?hw.disk_read_mb/1024:null,diskWriteMbs:hw.disk_write_mb??null},
hardware:{...fallback.hardware,gpus:readings.length?readings:fallback.hardware.gpus,gpuLoad:hw.gpu_util??fallback.hardware?.gpuLoad??null,vramUsedGb:gb(hw.gpu_mem_used)??fallback.hardware?.vramUsedGb??null,vramTotalGb:gb(hw.gpu_mem_total)??fallback.hardware.vramTotalGb,gpuTempC:hw.gpu_temp??fallback.hardware?.gpuTempC??null,powerW:hw.gpu_power??fallback.hardware?.powerW??null,powerLimitW:hw.gpu_power_limit??fallback.hardware?.powerLimitW??null,pcieRxMbs:hw.gpu_pcie_rx_mb??fallback.hardware?.pcieRxMbs??null,cpuLoad:hw.cpu??fallback.hardware.cpuLoad,ramUsedGb:gb(hw.ram_used)??fallback.hardware.ramUsedGb,ramTotalGb:gb(hw.ram_total)??fallback.hardware.ramTotalGb,diskReadGbs:finite(hw.disk_read_mb)?hw.disk_read_mb/1024:null,diskWriteMbs:hw.disk_write_mb??null},
history:samples.length?samples:fallback.history,requests,
totals:totals?{...fallback.totals,requests:totals.requests??0,tokensIn:totals.prompt_tokens??0,tokensOut:totals.output_tokens??0,cachedTokens:totals.reused??0,uncachedTokens:(totals.prompt_tokens??0)-(totals.reused??0),promptMs:totals.prompt_ms,decodeMs:totals.decode_ms,since:totals.since?new Date(totals.since*1000).toISOString():fallback.totals.since}:fallback.totals,
conversationCache:cache?{source:'engine',enabled:cache.enabled,parked:cache.parked,slots:cache.slots,memoryGb:gb(cache.bytes),budgetGb:finite(cache.budget_mib)?cache.budget_mib/1024:null,parks:cache.parks,restores:cache.restores,evictions:cache.evictions,lastEvent:cache.last_event,lastAt:cache.last_at,lastTokens:cache.last_tokens,requests:cache.requests,requestsReused:cache.requests_reused,lastPrompt:cache.last_prompt,lastReused:cache.last_reused}:fallback.conversationCache,
+68 -10
View File
@@ -13,6 +13,7 @@ import { USER_KEY, capture as captureUserArgs, merge as mergeUserArgs, setFlag a
import { resolvePython, venvPython } from './python-resolver.mjs';
import os from 'node:os';
import { runtimeVision } from './native-vision.mjs';
import { dsEngineArgs, dsUserArgs, dsArgsNote, withoutVisionFlag } from './native-ds-args.mjs';
import { componentsDirectory, installerEnvironment, controlEnvironment, sourceServerEnvironment } from './control-runtime.mjs';
import { createEngineStep } from './engine-step.mjs';
import { dataFolderFromPick, isDriveRoot, makeFolder, rememberDataFolder, syncDataFolder, terminalSettingsFile } from './model-folder.mjs';
@@ -223,6 +224,49 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
const DS_EXPERTS_MIN=[0,1,50];
function dsEngineVersion(config){const m=/engine-dsv41[\\/](\d+(?:\.\d+)+)-/.exec(String(config?.exe??''))||/engine-dsv41[\\/](\d+(?:\.\d+)+)-/.exec(String((config?.lib_dirs??[])[0]??''));return m?m[1].split('.').map(Number):null;}
function dsExpertsSupported(config){const v=dsEngineVersion(config);if(!v)return false;for(let i=0;i<DS_EXPERTS_MIN.length;i++){const a=v[i]??0,b=DS_EXPERTS_MIN[i];if(a!==b)return a>b;}return true;}
/* Control's ceiling for one start wait, read from the Control this app actually found rather than guessed from its version. The
app starts a model with `strata runtime start --wait`, and that wait is checked three times before the engine is reached: by
management.runtime_action, by the operation record written before the start (journal), and by runtime.start itself. 27cbbfe6
raised only the runtime's, so a longer wait died with `start wait must be finite seconds in 0..600` and then with
`direct runtime intent has an invalid timeout`. The app therefore asks for the longer wait only when a Control's own sources
say all three agree, and keeps today's 600 s whenever one of them still names a ceiling of its own. Reading the installed
Control to learn what it supports is what the field list and the /v1/responses capability already do. */
const START_WAIT_CEILING=600;
let ceilingCache={dir:'',time:0,value:START_WAIT_CEILING};
function followsRuntimeCeiling(text,marker){
/* The marker picks the one line that bounds the wait (`"wait_s": (` in the record's range table, `"start wait"` in the CLI
validator), because both files name those keys elsewhere. A line that still spells out 600 is a ceiling of its own. */
const line=(String(text).split(/\r?\n/).find(l=>l.includes(marker))??'');
return /runtime\.MAX_START_WAIT|start_wait_max/.test(line)&&!/\b600\b/.test(line);
}
function startWaitCeiling(){
const dir=control?.control?.dir??'';if(!dir)return START_WAIT_CEILING;
const files=['management.py','journal.py','runtime.py'].map(name=>join(dir,'strata_control',name));
let time=0;
try{time=Math.max(...files.map(f=>statSync(f).mtimeMs));}catch{return START_WAIT_CEILING;}
if(ceilingCache.dir===dir&&ceilingCache.time===time)return ceilingCache.value;
let value=START_WAIT_CEILING;
try{
const [management,journal]=files.slice(0,2).map(f=>readFileSync(f,'utf8'));
// both gates must hand the ceiling to the runtime, or the wait would be refused on the way to it
if(followsRuntimeCeiling(management,'"start wait"')&&followsRuntimeCeiling(journal,'"wait_s": (')){
const declared=Number(readFileSync(files[2],'utf8').match(/^MAX_START_WAIT\s*=\s*(\d+)/m)?.[1]);
if(Number.isFinite(declared)&&declared>value)value=declared;
}
}catch{value=START_WAIT_CEILING;}
ceilingCache={dir,time,value};return value;
}
/* How long a start waits for this model, in the same shape as `strata launch`'s figure (#3949 #3459 #4191): ten minutes, about eight
more seconds per GB above 40 GB, never more than this Control accepts. A model nobody can size keeps the 300 s it always had.
The run config's engine_ready_s is the floor, not the ceiling: the server reads that number and ends the engine when it runs out,
so the start must never give up before the server does (setup writes 3600 for DeepSeek V4.1-Flash). */
function startWaitFor(config,entry,model){
const ceiling=startWaitCeiling(),ready=Number(config?.engine_ready_s)||0;
const known=Number(entry?.diskGb),fromDisk=Number.isFinite(known)&&known>0?known:0;
const gb=fromDisk||Number((snapshot.catalog?.families??[]).flatMap(f=>f.sizes??[]).find(s=>s.id===model)?.download_gb)||0;
const sized=gb>40?Math.max(300,600+(gb-40)*8):300;
return Math.trunc(Math.max(300,Math.min(ceiling,Math.max(ready,sized))));
}
function buildConfig(args,config){
const vision=runtimeVision(config,args.vision);
const visionPreferences=args.vision!==undefined?{ui_vision_enabled:args.vision!=='no',ui_vision_device:args.vision==='no'?config.ui_vision_device??'yes':args.vision}:{};
@@ -243,18 +287,22 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
if(cfg.vision){if(cfg.vision_tokens!=null)cfg.vision={...cfg.vision,max_tokens:cfg.vision_tokens};if(cfg.vision_min_tokens!=null)cfg.vision={...cfg.vision,min_tokens:cfg.vision_min_tokens};}cfg.mcp={...(cfg.mcp??{}),...(cfg.mcp_timeout_s!=null?{timeout_s:cfg.mcp_timeout_s}:{}),...(cfg.mcp_max_rounds!=null?{max_rounds:cfg.mcp_max_rounds}:{}),...(cfg.mcp_max_result_chars!=null?{max_result_chars:cfg.mcp_max_result_chars}:{})};
/* The model's own choices win over what setup wrote into the arguments: "Draft layer (MTP)" off leaves --mtp out of every start, and a picture reading that is off leaves --vision out (setup writes both again whenever it runs; the saved settings are applied here, on every load). */
if((cfg.mtp===false||cfg.drafting==='off')&&Array.isArray(cfg.args))cfg.args=withoutDraftLayer(cfg.args);
if(!cfg.vision&&Array.isArray(cfg.args))cfg.args=cfg.args.filter(a=>a!=='--vision');
/* The Qwen engine's --vision stands alone, so it goes (with a picture word after it, if an older file has one). The DeepSeek engine's is a pair, `--vision on|off`, that setup wrote and the engine reads: it stays whole (taking out only the name left `off` behind: `--eos 1 off`, "unknown option off", #4358 #4359 #4505 #4507). */
if(!cfg.vision&&!ownEngine(config)&&Array.isArray(cfg.args))cfg.args=withoutVisionFlag(cfg.args);
for(const arg of cfg.args??[])if(arg.endsWith('.gguf')&&!existsSync(resolve(repo(),arg)))throw new Error('An engine argument names a missing GGUF. Restore the saved arguments or repair the files.');
/* The app's own choice of request slots (4, fewer where the card has no room) is part of the configuration, so a tune measures the engine a load will start: slots take VRAM from the expert cache. */
const parallelChoice=packaged&&!ownEngine(config)?chooseParallel({config:cfg,values:args.values??{},hardware:snapshot.hardware?.gpus?.length?snapshot.hardware:{gpus:hardwareProbe()??[]}}):null;
let parallelNote=null;
let parallelNote=null,dsNote='';
/* The agent asks for long answers (a thinking model can use most of the window): let the engine clamp the output to what is left of the context instead of refusing the request. */
if(packaged&&!ownEngine(config)&&cfg.fit_max_tokens===undefined)cfg.fit_max_tokens=true;
if(parallelChoice){const applied=applyParallel(cfg,parallelChoice);for(const k of Object.keys(cfg))if(!(k in applied.cfg))delete cfg[k];Object.assign(cfg,applied.cfg);parallelNote=applied.note;}
/* DeepSeek's Speed choice: the engine decides by itself (--experts auto: 5 when the cards total 16 GB or less, else 6), so nothing is passed unless the person chose 5 or 6. */
if(ownEngine(config)){const list=[];const old=Array.isArray(cfg.args)?cfg.args:[];for(let i=0;i<old.length;i++){if(old[i]==='--experts'){i++;continue;}list.push(old[i]);}if(dsExpertsSupported(config)&&(cfg.ds_experts==='5'||cfg.ds_experts==='6'))list.push('--experts',cfg.ds_experts);else if(!dsExpertsSupported(config)&&old.includes('--experts'))list.splice(0,list.length,...old);cfg.args=list;delete cfg.ds_experts;}
if(ownEngine(config)){const list=[];const old=Array.isArray(cfg.args)?cfg.args:[];for(let i=0;i<old.length;i++){if(old[i]==='--experts'){i++;continue;}list.push(old[i]);}if(dsExpertsSupported(config)&&(cfg.ds_experts==='5'||cfg.ds_experts==='6'))list.push('--experts',cfg.ds_experts);else if(!dsExpertsSupported(config)&&old.includes('--experts'))list.splice(0,list.length,...old);cfg.args=list;delete cfg.ds_experts;
/* Only the options strata-dsv41 takes go to it (#4504): --vram-reserve-mib is its --reserve-mib, and --pool-workers and the other Qwen-engine names are left out, in the arguments and in the person's saved options. */
const built=dsEngineArgs(cfg.args);cfg.args=built.args;dsNote=dsArgsNote(built);
if(Array.isArray(cfg[USER_KEY])){const kept=dsUserArgs(cfg[USER_KEY]);if(kept.length)cfg[USER_KEY]=kept;else delete cfg[USER_KEY];}}
else delete cfg.ds_experts;
return {cfg,resources,visionPreferences,parallelChoice,parallelNote};
return {cfg,resources,visionPreferences,parallelChoice,parallelNote,dsNote};
}
/* The DFlash2 drafter, before a load that uses it: Control says whether the choice needs it (and the file is not here), downloads it pinned and checked, and packs it, with plain progress in the job.
Never stops the load: a drafter that cannot be had leaves the MTP running, and the job says so. The choice itself is remembered in the model's settings file. */
@@ -279,7 +327,7 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
await refresh(true);if(job.state!=='running')return;const {entry,path:configFile}=recordFile(args.model);const config=checkedSettingsConfig(readFileSync(configFile,'utf8'));if(packaged){const engine=await control.engine();if(!engine.usable)throw new Error(ENGINE_NOT_RECOGNISED+(engine.notice?` ${engine.notice}`:''));}if(!entry.ready){const error=missingFilesError(args.model,entry.missing);append(args.model,error.details[0]);throw error;}
if(externalRunning().length)throw new Error('A Strata server is already running outside this UI. Stop it before loading here, or connect its API in Settings → Models.');
if(args.multiple&&instances.size>=8)throw new Error('Unload an instance before adding another. Up to eight native instances can run here.');
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote}=buildConfig(args,config);
const {cfg,resources,visionPreferences,parallelChoice,parallelNote:chosenNote,dsNote}=buildConfig(args,config);
let parallelNote=chosenNote;
await prepareDrafter(job,args,cfg,configFile);if(job.state!=='running')return;
const fingerprint=createHash('sha256').update(JSON.stringify({...cfg,api_key:undefined,port:undefined})).digest('hex');
@@ -300,12 +348,19 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
/* Another model on the same graphics card: the engine takes the card's free memory, so a second one usually finds none. Loading goes on (recommend, never force); if it fails, the reason is said. */
const sharedWith=instance.gpus.length?[...instances.values()].find(i=>i.id!==id&&i.status!=='error'&&i.model!==args.model&&i.gpus.some(g=>instance.gpus.includes(g))):null;
const sharedSentence=sharedWith?`GPU ${instance.gpus.find(g=>sharedWith.gpus.includes(g))+1} is already used by ${sharedWith.label}, so this model did not fit there. Load it on another GPU, or unload ${sharedWith.label} first.`:null;
let proc=null,started=null,retried=null;
let proc=null,started=null,retried=null,startWait=null;
if(packaged){
/* How long this start waits, and the server's own engine wait kept above it. The server ends the engine when its engine wait runs
out (900 s, or the config's engine_ready_s), so a start that waits longer than that can never win: as in `strata launch`, the
server gets a wait above the start's. It is only ever raised, never lowered, so a config that already names a longer one
(setup's 3600 for DeepSeek V4.1-Flash) is left alone. */
startWait=startWaitFor(config,entry,args.model);
if(startWait+60>Math.max(900,Number(cfg.engine_ready_s)||0))cfg.engine_ready_s=startWait+60;
/* The engine starts under Control's owner runtime, not as our child: a saved profile per slot, then `runtime start`. */
const used=new Set([...instances.values()].map(i=>i.profile));let slot=1;while(used.has('app-'+slot))slot++;instance.profile='app-'+slot;
instances.set(id,instance);if(!defaultId||!args.multiple||wasDefault)defaultId=id;emit();
const startWith=value=>{writeFileSync(configPath,JSON.stringify(value,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:ownEngine(config)?Math.min(600,Math.max(300,Number(config.engine_ready_s)||300)):300}));};
if(dsNote)append(id,dsNote);
const startWith=value=>{writeFileSync(configPath,JSON.stringify(value,null,2),{mode:0o600});return control.stop(instance.profile).catch(()=>{}).then(()=>control.saveProfile(instance.profile,configPath)).then(()=>control.start(instance.profile,{waitSeconds:startWait}));};
started=startWith(cfg);
started.then(()=>append(id,'Strata Control reports the engine is ready.'),error=>{append(id,error.message);});
try{await started;}catch(firstError){let error=firstError;
@@ -397,16 +452,19 @@ return {summary:result.summary??'Deleted',removed:result.plan?.gb??null};}
function writeModelConfig(path,next){const mine=mergeUserArgs(captureUserArgs(readJson(path)),next[USER_KEY]??[]);next=stampedUserArgs(next,mine);copyFileSync(path,path+'.bak');chmodSync(path+'.bak',0o600);const temp=path+'.ui-tmp';writeFileSync(temp,JSON.stringify(next,null,2)+'\n',{mode:0o600});renameSync(temp,path);}
async function tune(job,args){
await refresh(true);if(job.state!=='running')return;
const chosen=record(args.model);resourceSettings(chosen.config,snapshot.hardware,args.profile,modelArena(args.model));
const chosen=record(args.model);
/* The resource preference writes the Qwen engine's options (--pool-workers, --vram-reserve-mib); the DeepSeek engine takes neither (#4504), so its settings file is left as it is. */
const resourceFor=config=>ownEngine(config)?{}:resourceSettings(config,snapshot.hardware,args.profile,modelArena(args.model));
resourceFor(chosen.config);
if(!chosen.entry.ready)throw new Error('Prepare the model before tuning.');
if(externalRunning().length)throw new Error('Stop the native server outside this UI before tuning.');
if(busy(requests().filter(r=>instances.has(r.instanceId))))throw new Error('Finish requests before tuning.');
for(const id of [...instances.keys()])await unload(id);
if(job.state!=='running')return;
const initialValues=resourceSettings(chosen.config,snapshot.hardware,args.profile,modelArena(args.model));writeModelConfig(chosen.path,{...chosen.config,...initialValues,ui_resource_profile:args.profile});
const initialValues=resourceFor(chosen.config);writeModelConfig(chosen.path,{...chosen.config,...initialValues,ui_resource_profile:args.profile});
if(args.calibrate!==false&&state().capabilities.calibration){job.summary='Measuring the best engine settings for this computer…';emit();await program(job,args);}
if(job.state!=='running')return;
const current=record(args.model),values=resourceSettings(current.config,snapshot.hardware,args.profile,modelArena(args.model));
const current=record(args.model),values=resourceFor(current.config);
const next={...current.config,...values,ui_resource_profile:args.profile};
writeModelConfig(current.path,next);
job.summary='Applying your resource preference and loading the model…';emit();
+91
View File
@@ -0,0 +1,91 @@
/** The engine options of the DeepSeek-V4.1 engine (strata-dsv41), and the two ways the app used to hand it options it does not take.
*
* strata-dsv41 is its own program (src/dsv41/ds4_main.cpp) with its own short option list, and it exits with "unknown option X" (code 2)
* on anything else. The Qwen engine takes other names (--vram-reserve-mib, --pool-workers, --resident-budget-gib, ...), and the app's
* settings, resource profile and tune presets write those into a model's "args" and "user_args". Before the load starts, the arguments
* of a DeepSeek model are built from what the engine takes (#4504): a name it spells differently is renamed, anything else is left out
* with its value and said in the job's log. test/native-ds-args.test.mjs reads ds4_main.cpp and fails when this list and the engine drift.
*/
/** Options that take one value. */
export const DS_VALUE_FLAGS = new Set([
'--pack', '--gpus', '--max-context', '--reserve-mib', '--vram-experts', '--cpu-threads', '--expert-profile', '--ssd-cache', '--ssd-policy',
'--eos', '--dspark', '--dspark-conf', '--dspark-block', '--dspark-depth', '--experts', '--tp', '--prefill-chunk', '--threads', '--vision',
'--mmap-experts', '--layer-split',
]);
/** Options that stand alone. */
export const DS_SWITCH_FLAGS = new Set(['--no-expert-cache', '--profile-readonly', '--verbose', '-v']);
/** The Qwen engine's names for settings the DeepSeek engine has under another name (same meaning). */
export const DS_RENAMED = { '--vram-reserve-mib': '--reserve-mib' };
const isFlag = text => typeof text === 'string' && text.startsWith('-') && text.length > 1 && /^-{1,2}[A-Za-z]/.test(text);
/** The words that follow `flag` at `at` up to the next option: its value (one word, or none for a switch at the end of the list). */
function valueSpan(list, at) {
return at + 1 < list.length && !isFlag(list[at + 1]) ? 1 : 0;
}
/** `args` for strata-dsv41: accepted options kept (a renamed one under the engine's name, unless the list already has that name),
* everything else left out together with its value. Returns {args, dropped: [{flag, value}], renamed: [{from, to}]}. */
export function dsEngineArgs(args) {
const list = (Array.isArray(args) ? args : []).map(String);
const out = [], dropped = [], renamed = [];
const has = new Set(list.filter(isFlag));
for (let i = 0; i < list.length; i++) {
const item = list[i];
if (!isFlag(item)) { out.push(item); continue; } // a stray word: kept as it was (the engine says what it is)
const own = DS_RENAMED[item];
if (own) {
const n = valueSpan(list, i), value = n ? list[i + 1] : null;
i += n;
if (has.has(own)) { dropped.push({ flag: item, value, because: `${own} is set` }); continue; }
out.push(own, ...(value === null ? [] : [value]));
renamed.push({ from: item, to: own });
continue;
}
const takes = DS_VALUE_FLAGS.has(item), alone = DS_SWITCH_FLAGS.has(item);
if (takes || alone) {
out.push(item);
if (takes && i + 1 < list.length && !isFlag(list[i + 1])) out.push(list[++i]);
continue;
}
const n = valueSpan(list, i);
dropped.push({ flag: item, value: n ? list[i + 1] : null });
i += n;
}
return { args: out, dropped, renamed };
}
/** The person's own options of a DeepSeek model: unaccepted ones leave the list (so the settings file heals itself); the app's
* --vram-reserve-mib stays, because the Settings field writes it under that name and it is renamed at every load. */
export function dsUserArgs(user) {
const list = (Array.isArray(user) ? user : []).map(String);
const out = [];
for (let i = 0; i < list.length; i++) {
const item = list[i];
if (!isFlag(item)) { out.push(item); continue; }
const n = valueSpan(list, i);
const keep = DS_VALUE_FLAGS.has(item) || DS_SWITCH_FLAGS.has(item) || item in DS_RENAMED;
if (keep) out.push(item, ...list.slice(i + 1, i + 1 + n));
i += n;
}
return out;
}
/** One line for the job's log about what was changed, or '' when nothing was. */
export function dsArgsNote({ dropped = [], renamed = [] }) {
const parts = [];
if (renamed.length) parts.push(`${renamed.map(r => `${r.from} is passed as ${r.to}`).join(', ')}`);
if (dropped.length) parts.push(`left out because the DeepSeek engine does not take them: ${dropped.map(d => [d.flag, d.value].filter(v => v !== null && v !== undefined).join(' ')).join(', ')}`);
return parts.length ? `DeepSeek engine options: ${parts.join('; ')}.` : '';
}
/** `args` without `flag` and the picture word that follows it (the Qwen engine's --vision stands alone; an older config may carry a value). */
const PICTURE_WORDS = new Set(['on', 'off', 'yes', 'no', 'gpu', 'cpu', 'none']);
export function withoutVisionFlag(args) {
const list = (Array.isArray(args) ? args : []).map(String), out = [];
for (let i = 0; i < list.length; i++) {
if (list[i] === '--vision') { if (i + 1 < list.length && PICTURE_WORDS.has(list[i + 1].toLowerCase())) i++; continue; }
out.push(list[i]);
}
return out;
}
+4 -3
View File
@@ -32,7 +32,8 @@ export const ENV_FLAG=/^(?:STRATA|DS4)_[A-Z0-9_]+$/;
* The speed features of 0.2.7 that have a switch in Expert settings. A switch has a default ('on' or 'off'); outside it, it writes exactly the
* environment entries `env` into the model's settings file ("env"); back in its default it removes them, so a default launch carries none of them.
* "Faster long prompts" (Qwen KVA) is OFF by default (owner decision, 26 October): STRATA_KVA=1 turns it on, and an earlier test build's STRATA_KVA=0 reads as off.
* The other two are on by default. The names are the engine switch contract (phaseA-tests/notes-027-plan.md); strata_control/speed_switches.py says the
* "Faster answers on 12 GB NVIDIA cards" (residency-biased routing) is OFF by default since 0.2.8 (owner decision, 29 October; #4402): STRATA_ROUTE_PRIOR=1.5
* turns it on, and the STRATA_ROUTE_PRIOR=0 that 0.2.7's Off wrote is the default state now. The other two are on by default. The names are the engine switch contract (phaseA-tests/notes-027-plan.md); strata_control/speed_switches.py says the
* same for the Terminal (tests/terminal/test_speed_switches.py compares the two).
*/
export const SPEED_SWITCHES=[
@@ -40,8 +41,8 @@ export const SPEED_SWITCHES=[
hint:'Reads very long prompts faster by approximating part of the work. Off by default: answers about long documents can miss or mix up details.',
default:'off',env:{STRATA_KVA:'1'}},
{id:'faster_12gb_nvidia',label:'Faster answers on 12 GB NVIDIA cards',group:'Speed',
hint:'Prefers the parts of the model already on the graphics card, which makes answers much faster on 12 GB cards. Turn off if answers look worse than before.',
default:'on',env:{STRATA_ROUTE_PRIOR:'0'}},
hint:'Prefers the parts of the model already on the graphics card, which makes answers faster on 12 GB cards. Off by default: in long agent sessions it can repeat itself and give weaker results. Turn on if you mostly chat and want the speed.',
default:'off',env:{STRATA_ROUTE_PRIOR:'1.5'}},
{id:'faster_radeon_9000',label:'Faster answers on Radeon RX 9000 / AI PRO R9700',group:'Speed',
hint:'Shares the work between the graphics card and the processor in a way that makes answers faster on these cards. Turn off to go back to the previous setting, which is slower.',
default:'on',env:{STRATA_PCIE_FRAC_DEFAULT:'old'}},
+20 -6
View File
@@ -3,7 +3,11 @@
* - size: the DirectX adapter list in the registry (HKLM\SOFTWARE\Microsoft\DirectX) has a 64-bit DedicatedVideoMemory and the adapter
* LUID. Win32_VideoController.AdapterRAM is 32 bits and reads 0 or 4 GB for a 16 GB card (#3727, #3836).
* - load and memory in use: the Windows GPU performance counters (the ones Task Manager shows), read by one long-lived PowerShell and
* matched to the card by LUID. Temperature, power and the PCIe link need the vendor's own library, so they stay "not available".
* matched to the card by LUID.
* - temperature: the display driver's own D3DKMT adapter performance data (gdi32.dll, no vendor library, no administrator), asked for the
* same LUID. An AMD 7900 XTX gave the same value as ADLX at idle and under load (noctrex, #3727); the card answers in tenths of a degree.
* A driver that does not answer leaves the temperature "not available". Power, fan and the PCIe link stay "not available": D3DKMT gave
* 0 for power and for the PCIe rate on that card.
*/
import { createPowerShellLoop, endWithHost } from './windows-poll.mjs';
import { utf8Script } from './powershell-utf8.mjs';
@@ -55,20 +59,30 @@ export function windowsOtherGpus(videoRows, dx) {
return out;
}
/** One counter line: "G <luid> <load percent> <dedicated bytes in use>". */
export function parseGpuCounterLine(line) {
const m = /^G\s+(luid_0x[0-9a-f]+_0x[0-9a-f]+)\s+(\d+(?:\.\d+)?)\s+(\d+)\s*$/i.exec(String(line));
return m ? { luid: m[1].toLowerCase(), utilizationPct: Math.max(0, Math.min(100, Number(m[2]))), memoryUsedGb: Number(m[3]) / 1024 ** 3 } : null;
/** The temperature D3DKMT reports in tenths of a degree -> whole degrees, or null when the driver gave nothing believable. */
export function d3dkmtTemperatureC(deci) {
const c = Number(deci) / 10;
return Number.isFinite(c) && c > 0 && c <= 150 ? Math.round(c) : null;
}
/** One counter line: "G <luid> <load percent> <dedicated bytes in use> [<temperature in tenths of a degree>]". */
export function parseGpuCounterLine(line) {
const m = /^G\s+(luid_0x[0-9a-f]+_0x[0-9a-f]+)\s+(\d+(?:\.\d+)?)\s+(\d+)(?:\s+(-?\d+))?\s*$/i.exec(String(line));
return m ? { luid: m[1].toLowerCase(), utilizationPct: Math.max(0, Math.min(100, Number(m[2]))), memoryUsedGb: Number(m[3]) / 1024 ** 3, temperatureC: d3dkmtTemperatureC(m[4]) } : null;
}
/** The C# that asks D3DKMT (KMTQAITYPE_ADAPTERPERFDATA = 62, 64-byte D3DKMT_ADAPTER_PERFDATA, Temperature at byte 56) for one LUID; -1 = no answer. */
export const GPU_TEMP_CSHARP = 'using System;using System.Runtime.InteropServices;public static class StrataGpuTemp{[StructLayout(LayoutKind.Sequential)]struct Q{public uint L;public int H;public uint A;}[StructLayout(LayoutKind.Sequential)]struct Info{public uint A;public int T;public IntPtr P;public uint S;}[DllImport("gdi32.dll")]static extern int D3DKMTOpenAdapterFromLuid(ref Q q);[DllImport("gdi32.dll")]static extern int D3DKMTCloseAdapter(ref uint a);[DllImport("gdi32.dll")]static extern int D3DKMTQueryAdapterInfo(ref Info i);public static int Read(int high,uint low){Q q=new Q();q.L=low;q.H=high;if(D3DKMTOpenAdapterFromLuid(ref q)!=0)return -1;IntPtr buf=Marshal.AllocHGlobal(64);try{for(int k=0;k<64;k++)Marshal.WriteByte(buf,k,0);Info i=new Info();i.A=q.A;i.T=62;i.P=buf;i.S=64;if(D3DKMTQueryAdapterInfo(ref i)!=0)return -1;return Marshal.ReadInt32(buf,56);}finally{Marshal.FreeHGlobal(buf);uint a=q.A;D3DKMTCloseAdapter(ref a);}}}';
export function createWindowsGpuReader({ spawnFn, intervalSec = 4, maxStarts = 3, now = Date.now } = {}) {
const readings = new Map();
const script = utf8Script(
`$t=$false;try{Add-Type -TypeDefinition '${GPU_TEMP_CSHARP}';$t=$true}catch{};` +
`while($true){` +
`$e=@(Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine);$m=@(Get-CimInstance Win32_PerfFormattedData_GPUPerformanceCounters_GPUAdapterMemory);` +
`$u=@{};foreach($r in $e){if($r.Name -match '(luid_0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)_phys_\\d+_eng_\\d+_engtype_(\\w+)'){$k=$matches[1].ToLower()+'|'+$matches[2];$u[$k]=[double]$u[$k]+[double]$r.UtilizationPercentage}};` +
`$b=@{};foreach($k in $u.Keys){$l=$k.Split('|')[0];if(-not $b.ContainsKey($l) -or $u[$k] -gt $b[$l]){$b[$l]=$u[$k]}};` +
`foreach($r in $m){if($r.Name -match '(luid_0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)'){$l=$matches[1].ToLower();[Console]::Out.WriteLine('G '+$l+' '+[math]::Round([double]$b[$l],0)+' '+$r.DedicatedUsage)}};` +
`foreach($r in $m){if($r.Name -match '(luid_0x[0-9A-Fa-f]+_0x[0-9A-Fa-f]+)'){$l=$matches[1].ToLower();$d=-1;if($t){try{$p=$l.Split('_');$d=[StrataGpuTemp]::Read([Convert]::ToInt32($p[1].Substring(2),16),[Convert]::ToUInt32($p[2].Substring(2),16))}catch{}};[Console]::Out.WriteLine('G '+$l+' '+[math]::Round([double]$b[$l],0)+' '+$r.DedicatedUsage+' '+$d)}};` +
`[Console]::Out.Flush();Start-Sleep -Seconds ${intervalSec}}`);
const loop = createPowerShellLoop({ script, spawnFn, maxStarts, onLine: line => {
const parsed = parseGpuCounterLine(line);
+1 -1
View File
@@ -96,7 +96,7 @@ export function SettingsUpdates() {
</>;
}
/** "Strata 0.2.7 · Engine 0.1.52": the app's own version first, the engine's beside it (About). */
/** "Strata 0.2.8 · Engine 0.1.54": the app's own version first, the engine's beside it (About). */
export function AboutVersions() {
const native = useNative();
const app = appVersionLabel(useStore(s => s.setup?.app.version), native?.version);
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "Braucht etwa {0} GB freien Speicherplatz. Was nicht in den Speicher passt, wird von Ihrer SSD gelesen.",
"fx81": "{0} RAM + VRAM, dazu etwa {1} GB Speicherplatz",
"fx82": "Schnellere Antworten auf NVIDIA-Karten mit 12 GB",
"fx83": "Bevorzugt die Teile des Modells, die schon auf der Grafikkarte liegen. Das macht Antworten auf Karten mit 12 GB deutlich schneller. Schalten Sie die Option aus, wenn die Antworten schlechter aussehen als vorher."
"fx83": "Bevorzugt die Teile des Modells, die schon auf der Grafikkarte liegen. Das macht Antworten auf Karten mit 12 GB schneller. Standardmäßig aus: In langen Agent-Sitzungen kann sich das Modell wiederholen und schwächere Ergebnisse liefern. Schalten Sie die Option ein, wenn Sie vor allem chatten und die Geschwindigkeit wollen."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "Needs about {0} GB of free disk space. What does not fit in memory is read from your SSD.",
"fx81": "{0} RAM + VRAM, plus about {1} GB of disk",
"fx82": "Faster answers on 12 GB NVIDIA cards",
"fx83": "Prefers the parts of the model already on the graphics card, which makes answers much faster on 12 GB cards. Turn off if answers look worse than before."
"fx83": "Prefers the parts of the model already on the graphics card, which makes answers faster on 12 GB cards. Off by default: in long agent sessions it can repeat itself and give weaker results. Turn on if you mostly chat and want the speed."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "Necesita unos {0} GB de espacio libre en disco. Lo que no cabe en memoria se lee de tu SSD.",
"fx81": "{0} de RAM + VRAM, más unos {1} GB de disco",
"fx82": "Respuestas más rápidas en tarjetas NVIDIA de 12 GB",
"fx83": "Prefiere las partes del modelo que ya están en la tarjeta gráfica, lo que hace las respuestas mucho más rápidas en tarjetas de 12 GB. Desactívalo si las respuestas parecen peores que antes."
"fx83": "Prefiere las partes del modelo que ya están en la tarjeta gráfica, lo que hace las respuestas más rápidas en tarjetas de 12 GB. Desactivado por defecto: en sesiones largas de agente puede repetirse y dar resultados más débiles. Actívalo si sobre todo chateas y quieres la velocidad."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "Il faut environ {0} Go d’espace disque libre. Ce qui ne tient pas en mémoire est lu depuis votre SSD.",
"fx81": "{0} de RAM + VRAM, plus environ {1} Go de disque",
"fx82": "Réponses plus rapides sur les cartes NVIDIA de 12 Go",
"fx83": "Privilégie les parties du modèle déjà présentes sur la carte graphique, ce qui accélère nettement les réponses sur les cartes de 12 Go. Désactivez-le si les réponses semblent moins bonnes qu’avant."
"fx83": "Privilégie les parties du modèle déjà présentes sur la carte graphique, ce qui accélère les réponses sur les cartes de 12 Go. Désactivé par défaut : dans de longues sessions d’agent, le modèle peut se répéter et donner de moins bons résultats. Activez-le si vous discutez surtout et voulez la vitesse."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "約 {0} GB の空きディスク容量が必要です。メモリに入らない分は SSD から読み込みます。",
"fx81": "RAM + VRAM {0}、ほかにディスク約 {1} GB",
"fx82": "12 GB の NVIDIA カードでの高速な応答",
"fx83": "グラフィックスカード上にすでにあるモデルの部分を優先して使うため、12 GB のカードで応答がとても速くなります。以前より回答が悪くなったと感じたらオフにしてください。"
"fx83": "グラフィックスカード上にすでにあるモデルの部分を優先して使うため、12 GB のカードで応答が速くなります。初期設定ではオフです。長いエージェント作業では、同じ内容を繰り返したり、結果が弱くなったりすることがあります。主にチャットで使い、速度が欲しい場合はオンにしてください。"
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "약 {0}GB의 여유 디스크 공간이 필요합니다. 메모리에 들어가지 않는 부분은 SSD에서 읽습니다.",
"fx81": "RAM + VRAM {0}, 디스크 약 {1}GB 추가",
"fx82": "12GB NVIDIA 카드에서 더 빠른 응답",
"fx83": "이미 그래픽 카드에 올라와 있는 모델 부분을 우선 사용해 12GB 카드에서 응답이 훨씬 빨라집니다. 답변이 이전보다 나빠 보이면 끄세요."
"fx83": "이미 그래픽 카드에 올라와 있는 모델 부분을 우선 사용해 12GB 카드에서 응답이 더 빨라집니다. 기본값은 꺼짐입니다. 긴 에이전트 세션에서는 같은 말을 반복하거나 결과가 나빠질 수 있습니다. 주로 대화에 쓰고 속도를 원하면 켜세요."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "Precisa de cerca de {0} GB de espaço livre em disco. O que não cabe na memória é lido do seu SSD.",
"fx81": "{0} de RAM + VRAM, mais cerca de {1} GB de disco",
"fx82": "Respostas mais rápidas em placas NVIDIA de 12 GB",
"fx83": "Prefere as partes do modelo que já estão na placa de vídeo, o que deixa as respostas bem mais rápidas em placas de 12 GB. Desative se as respostas parecerem piores do que antes."
"fx83": "Prefere as partes do modelo que já estão na placa de vídeo, o que deixa as respostas mais rápidas em placas de 12 GB. Desativado por padrão: em sessões longas de agente ele pode se repetir e dar resultados mais fracos. Ative se você usa principalmente o chat e quer a velocidade."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "Нужно около {0} ГБ свободного места на диске. То, что не помещается в память, читается с вашего SSD.",
"fx81": "{0} RAM + VRAM, плюс около {1} ГБ на диске",
"fx82": "Более быстрые ответы на видеокартах NVIDIA с 12 ГБ",
"fx83": "Отдаёт предпочтение частям модели, которые уже находятся на видеокарте, поэтому ответы на картах с 12 ГБ становятся намного быстрее. Отключите, если ответы стали выглядеть хуже, чем раньше."
"fx83": "Отдаёт предпочтение частям модели, которые уже находятся на видеокарте, поэтому ответы на картах с 12 ГБ становятся быстрее. По умолчанию выключено: в длинных сессиях агента модель может повторяться и давать более слабые результаты. Включите, если вы в основном общаетесь в чате и хотите скорость."
}
+1 -1
View File
@@ -81,5 +81,5 @@
"fx80": "大约需要 {0} GB 可用磁盘空间。内存放不下的部分会从你的 SSD 读取。",
"fx81": "{0} 内存 + 显存,另需约 {1} GB 磁盘空间",
"fx82": "12 GB NVIDIA 显卡上更快的回答",
"fx83": "优先使用已经在显卡上的模型部分,使 12 GB 显卡上的回答快很多。如果回答看起来比以前差,请关闭。"
"fx83": "优先使用已经在显卡上的模型部分,使 12 GB 显卡上的回答更快。默认关闭:在长时间的智能体会话中,它可能重复内容并给出较弱的结果。如果你主要用来聊天并想要速度,请开启。"
}
+49 -2
View File
@@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseCpuTopology, parseNvidiaReadings, parsePcieThroughput, gpuMemoryTotals } from '../server/device-probes.mjs';
import { parseCpuTopology, parseNvidiaReadings, parsePcieThroughput, gpuMemoryTotals, pickGpuTemperature, isComputeAdapter } from '../server/device-probes.mjs';
import { cpuUtilization, diskRate, parseLinuxDiskCounters, parseMacDiskCounters } from '../server/system-telemetry.mjs';
import { parseEngineMetrics } from '../server/supervisor.mjs';
import { originalDashboard } from '../server/monitor-dashboard.mjs';
import { originalDashboard, mergeOwnReading } from '../server/monitor-dashboard.mjs';
import { gpuMetric, gpuCapacity, gpuReported, shortGpuName, defaultGpuSet, gpuId } from '../src/lib/monitor-hardware.ts';
import { referencePaths } from '../src/lib/monitor-chart.ts';
@@ -117,3 +117,50 @@ test('an RTX 5070 beside the unsupported Radeon iGPU: All GPUs is the RTX alone,
assert.deepEqual(defaultGpuSet([igpu]).map(g=>g.id),[igpu.id]);
assert.equal(defaultGpuSet([{...igpu,vramGb:96,unified:true},rtx]).length,2);
});
// ---- 0.2.8: Monitor readings for AMD cards (#3727) ----
const amd=(id,index)=>({id,index,unified:false,vramGb:16,name:'AMD Radeon RX 9070 XT',vendor:'amd',backend:'vulkan'});
const ownReading=(id,extra={})=>({id,source:'sensor',sampledAt:'t0',temperatureC:null,utilizationPct:37,memoryUsedGb:5.5,powerW:null,powerLimitW:null,pcieGen:null,pcieGenMax:null,pcieWidth:null,pcieRxMbs:null,...extra});
const shell=(own)=>({context:{maxTokens:32768},hardware:{gpus:own,gpuLoad:null},history:[],totals:{},telemetry:{}});
const rawNoGpu={engine:{max_context:32768},live:{state:'generating'},hardware:{cpu:12},history:{}};
test('Windows AMD, one card, engine running: the engine has no GPU reader, the host load and memory stay (#3727)',()=>{
const mapped=originalDashboard(rawNoGpu,{gpus:[amd('dx:1',0)]},shell([ownReading('dx:1')]));
assert.equal(mapped.hardware.gpus.length,1);
assert.equal(mapped.hardware.gpus[0].utilizationPct,37);
assert.equal(mapped.hardware.gpus[0].memoryUsedGb,5.5);
assert.equal(mapped.hardware.gpus[0].source,'sensor');
assert.equal(mapped.hardware.gpus[0].temperatureC,null,'a value nobody read stays unavailable');
});
test('engine numbers win, the host fills only the gaps (fan and clock come from the host)',()=>{
const raw={...rawNoGpu,hardware:{gpu_util:80,gpu_mem_used:4*1024**3,gpu_temp:61,gpu_power:200}};
const mapped=originalDashboard(raw,{gpus:[amd('a',0)]},shell([ownReading('a',{utilizationPct:10,temperatureC:50,fanPct:35,clockMhz:2400,powerLimitW:300})]));
const g=mapped.hardware.gpus[0];
assert.equal(g.utilizationPct,80); assert.equal(g.temperatureC,61); assert.equal(g.powerW,200);
assert.equal(g.powerLimitW,300); assert.equal(g.fanPct,35); assert.equal(g.clockMhz,2400); assert.equal(g.source,'engine');
});
test('a card the host has no reading for keeps the engine reading unchanged',()=>{
const e={id:'x',source:'engine',temperatureC:null,utilizationPct:5};
assert.equal(mergeOwnReading(e,undefined),e);
});
test('the headline aggregates fall back to the host when the engine reports none',()=>{
const fb=shell([ownReading('a')]); fb.hardware.gpuTempC=55; fb.hardware.vramUsedGb=5.5;
const mapped=originalDashboard(rawNoGpu,{gpus:[amd('a',0)]},fb);
assert.equal(mapped.hardware.gpuTempC,55); assert.equal(mapped.hardware.vramUsedGb,5.5);
});
test('amdgpu temperature: edge first (what nvtop shows), junction when there is no edge, 0 is no reading',()=>{
assert.equal(pickGpuTemperature([{label:'edge',milli:36000},{label:'junction',milli:38000},{label:'mem',milli:56000}]),36);
assert.equal(pickGpuTemperature([{label:'mem',milli:56000},{label:'junction',milli:48000}]),48);
assert.equal(pickGpuTemperature([{label:'',milli:41000}]),41);
assert.equal(pickGpuTemperature([{label:'edge',milli:0},{label:'junction',milli:44000}]),44);
assert.equal(pickGpuTemperature([{label:'edge',milli:0}]),null);
assert.equal(pickGpuTemperature([{label:'mem',milli:56000}]),null,'a memory sensor alone is not the GPU temperature');
assert.equal(pickGpuTemperature([]),null);
});
test('a remote-console chip with no memory is not a GPU; an unnamed card with memory and an Intel iGPU are',()=>{
assert.equal(isComputeAdapter('unknown',null),false);
assert.equal(isComputeAdapter('unknown',0),false);
assert.equal(isComputeAdapter('unknown',8*1024**3),true);
assert.equal(isComputeAdapter('intel',null),true);
assert.equal(isComputeAdapter('amd',34208743424),true);
});
+173
View File
@@ -0,0 +1,173 @@
/**
* The options the app hands to the DeepSeek engine (strata-dsv41), which exits with "unknown option X" on a name it does not take:
* - #4358 #4359 #4505 #4507: a model with pictures off had its `--vision off` pair cut in half (`--eos 1 off --dspark auto`);
* - #4504: the Settings field "GPU memory for other apps" wrote --vram-reserve-mib, the resource preference wrote --pool-workers.
* Nothing here starts an engine: Control is a fake that records the run config the host saved for the start (a scratch profile,
* never the real one).
*/
import './helpers/isolate-profile.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { createNativeControl } from '../server/native-control.mjs';
import { DS_VALUE_FLAGS, DS_SWITCH_FLAGS, DS_RENAMED, dsEngineArgs, dsUserArgs, dsArgsNote, withoutVisionFlag } from '../server/native-ds-args.mjs';
const DS_ARGS = ['--pack', '/p/ds', '--max-context', '32768', '--eos', '1', '--vision', 'off', '--dspark', 'auto', '--dspark-conf', '0.65', '--prefill-chunk', '1024'];
function fixture(config = {}, id = 'deepseek-v4.1-flash-ist-das-2.25bpw') {
const root = mkdtempSync(join(tmpdir(), 'strata-ds-args-'));
const install = join(root, 'install'), data = join(root, 'data');
mkdirSync(install, { recursive: true });
process.env.STRATA_INSTALL_DIR = install;
const path = join(install, `strata-${id}.json`);
writeFileSync(path, JSON.stringify({
exe: 'C:\\Users\\x\\AppData\\Local\\Strata\\engine-dsv41\\0.1.52-windows-x86_64-cuda-cuda13\\strata-dsv41.exe', arch: 'deepseek41',
tokenizer: join(install, 'tok'), args: DS_ARGS, model_name: 'DeepSeek V4.1', port: 8080, ...config }));
const saved = [];
const control = {
control: { dir: join(root, 'control') }, version: '0.2.7',
async installedModels() { return { install_dir: install, models: [{ id, name: 'DeepSeek V4.1', source_config: path, component_status: 'present', context_tokens: 32768 }] }; },
async engine() { return { usable: true, backend: 'cuda', components_dir: 'E:\\c', engine: { version: '0.1.52', platform: 'windows-x86_64', backend: 'cuda', exe: 'E:\\e\\strata.exe', root: 'E:\\e' } }; },
async saveProfile(name, file) { saved.push(JSON.parse(readFileSync(file, 'utf8'))); return {}; },
async start() { return { ok: true, state: 'ready' }; },
async stop() { return { ok: true }; },
};
const native = createNativeControl({ control, hardwareProbe: () => [{ index: 0, vram_gb: 24 }], directory: data, spawnProcess: () => { throw new Error('no process'); }, fetcher: async () => Response.json({ status: 'ok' }), requests: () => [] });
return { native, saved, id, path, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
const finish = async (native, id) => { for (let i = 0; i < 500; i++) { const job = native.state().jobs.find(j => j.id === id); if (job.state !== 'running') return job; await new Promise(r => setTimeout(r, 10)); } throw new Error('job did not finish'); };
/** Every word of the argument list that is not an option name or the value right after one: a stray word means a flag lost its name. */
function strayWords(args, valued) {
const stray = [];
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('-')) { if (valued.has(args[i]) && i + 1 < args.length && !args[i + 1].startsWith('--')) i++; continue; }
stray.push(args[i]);
}
return stray;
}
test('the DeepSeek option list is a subset of what ds4_main.cpp parses (it cannot drift ahead of the engine)', () => {
const source = fileURLToPath(new URL('../../../src/dsv41/ds4_main.cpp', import.meta.url));
if (!existsSync(source)) return; // a package without the engine source
const parsed = new Set([...readFileSync(source, 'utf8').matchAll(/a == "(-{1,2}[a-z][a-z0-9-]*)"/g)].map(m => m[1]));
assert.ok(parsed.has('--eos') && parsed.has('--reserve-mib'), 'the pattern found the engine option parser');
for (const flag of [...DS_VALUE_FLAGS, ...DS_SWITCH_FLAGS, ...Object.values(DS_RENAMED)]) assert.ok(parsed.has(flag), `${flag} is not an option of strata-dsv41`);
for (const flag of Object.keys(DS_RENAMED)) assert.ok(!parsed.has(flag), `${flag} is the Qwen engine's name, strata-dsv41 rejects it`);
assert.ok(!parsed.has('--pool-workers'), '--pool-workers is rejected by strata-dsv41');
});
test('dsEngineArgs renames --vram-reserve-mib, leaves out --pool-workers with its value and keeps the --vision pair', () => {
const r = dsEngineArgs([...DS_ARGS, '--vram-reserve-mib', '512', '--pool-workers', '20', '--resident-budget-gib', '40']);
assert.deepEqual(r.args, [...DS_ARGS, '--reserve-mib', '512']);
assert.deepEqual(r.renamed, [{ from: '--vram-reserve-mib', to: '--reserve-mib' }]);
assert.deepEqual(r.dropped.map(d => [d.flag, d.value]), [['--pool-workers', '20'], ['--resident-budget-gib', '40']]);
assert.match(dsArgsNote(r), /--vram-reserve-mib is passed as --reserve-mib/);
assert.match(dsArgsNote(r), /--pool-workers 20, --resident-budget-gib 40/);
assert.equal(dsArgsNote(dsEngineArgs(DS_ARGS)), '', 'nothing changed, nothing said');
});
test('dsEngineArgs: a reserve under both names keeps the engine\'s own, a switch at the end and values that look like numbers survive', () => {
assert.deepEqual(dsEngineArgs(['--reserve-mib', '300', '--vram-reserve-mib', '512']).args, ['--reserve-mib', '300']);
assert.deepEqual(dsEngineArgs(['--pack', '/p', '--no-expert-cache']).args, ['--pack', '/p', '--no-expert-cache']);
assert.deepEqual(dsEngineArgs(['--pool-workers']).args, [], 'a flag with no value');
assert.deepEqual(dsEngineArgs(['--spec', '4', '--tp', '2', '--kv', 'int8', '--experts', 'auto']).args, ['--tp', '2', '--experts', 'auto']);
assert.deepEqual(dsEngineArgs(undefined).args, []);
});
test('dsUserArgs drops what the engine rejects and keeps the options it takes and the app\'s reserve', () => {
assert.deepEqual(dsUserArgs(['--pool-workers', '20', '--vram-reserve-mib', '512', '--cpu-threads', '8']), ['--vram-reserve-mib', '512', '--cpu-threads', '8']);
assert.deepEqual(dsUserArgs(['--pool-workers', '20']), []);
});
test('withoutVisionFlag takes the Qwen engine\'s stand-alone --vision out, and a picture word an old file left after it', () => {
assert.deepEqual(withoutVisionFlag(['--max-context', '4096', '--vision', '--kv', 'int8']), ['--max-context', '4096', '--kv', 'int8']);
assert.deepEqual(withoutVisionFlag(['--a', '1', '--vision']), ['--a', '1']);
assert.deepEqual(withoutVisionFlag(['--vision', 'off', '--eos', '1']), ['--eos', '1']);
assert.deepEqual(withoutVisionFlag(['--vision', 'gpu', '--x']), ['--x']);
});
test('#4358 #4359 #4505 #4507: a DeepSeek load with pictures off leaves the --vision pair whole and no stray word', async () => {
const f = fixture();
try {
await f.native.refresh(true);
// what the Models screen sends for a model it does not list pictures for: vision "no"
const job = await finish(f.native, f.native.operation('load', { model: f.id, vision: 'no' }).id);
assert.equal(job.state, 'done', job.error);
const args = f.saved[0].args;
assert.equal(args[args.indexOf('--vision') + 1], 'off', args.join(' '));
assert.equal(args[args.indexOf('--eos') + 1], '1', 'the word after --eos is its value, not a left-over of --vision');
assert.equal(args[args.indexOf('--eos') + 2], '--vision');
assert.deepEqual(strayWords(args, new Set(['--pack', '--max-context', '--eos', '--vision', '--dspark', '--dspark-conf', '--prefill-chunk'])), []);
assert.deepEqual(args, DS_ARGS, 'nothing was added or removed');
} finally { await f.native.close(); f.cleanup(); }
});
test('a DeepSeek model with pictures on keeps `--vision on` too', async () => {
const f = fixture({ args: DS_ARGS.map(a => (a === 'off' ? 'on' : a)) });
try {
await f.native.refresh(true);
const job = await finish(f.native, f.native.operation('load', { model: f.id, vision: 'no' }).id);
assert.equal(job.state, 'done', job.error);
const args = f.saved[0].args;
assert.equal(args[args.indexOf('--vision') + 1], 'on');
assert.equal(args[args.indexOf('--eos') + 1], '1');
} finally { await f.native.close(); f.cleanup(); }
});
test('#4504: the reserve field and the resource preference reach strata-dsv41 as --reserve-mib, with no --pool-workers', async () => {
const f = fixture({ vram_reserve_mib: 512, ui_resource_profile: 'work', args: [...DS_ARGS, '--pool-workers', '20', '--vram-reserve-mib', '512'], args_written: [...DS_ARGS, '--pool-workers', '20', '--vram-reserve-mib', '512'], user_args: ['--pool-workers', '20'] });
try {
await f.native.refresh(true);
const job = await finish(f.native, f.native.operation('load', { model: f.id, vision: 'no', resourceProfile: 'work' }).id);
assert.equal(job.state, 'done', job.error);
const saved = f.saved[0], args = saved.args;
assert.ok(!args.includes('--pool-workers') && !args.includes('20'), args.join(' '));
assert.ok(!args.includes('--vram-reserve-mib'), args.join(' '));
assert.equal(args[args.indexOf('--reserve-mib') + 1], '512', args.join(' '));
assert.equal(args.filter(a => a === '--reserve-mib').length, 1);
assert.ok(!(saved.user_args ?? []).includes('--pool-workers'), 'the person\'s saved options lose it too');
for (const flag of args.filter(a => a.startsWith('--'))) assert.ok(DS_VALUE_FLAGS.has(flag) || DS_SWITCH_FLAGS.has(flag), `${flag} is an option of strata-dsv41`);
} finally { await f.native.close(); f.cleanup(); }
});
test('the resource preference no longer writes Qwen-engine options into a DeepSeek model\'s file when it is tuned', async () => {
const f = fixture({ args: DS_ARGS });
try {
await f.native.refresh(true);
const before = JSON.parse(readFileSync(f.path, 'utf8'));
const job = await finish(f.native, f.native.operation('tune', { model: f.id, profile: 'work', calibrate: false }).id);
void job; // the load and benchmark after it need a real engine; the file is what matters
const after = JSON.parse(readFileSync(f.path, 'utf8'));
assert.ok(!(after.args ?? []).includes('--pool-workers'), (after.args ?? []).join(' '));
assert.ok(!(after.args ?? []).includes('--vram-reserve-mib'), (after.args ?? []).join(' '));
assert.deepEqual(after.args.slice(0, before.args.length), before.args);
} finally { await f.native.close(); f.cleanup(); }
});
test('a Qwen model with pictures off still loses its stand-alone --vision (nothing else changed)', async () => {
const root = mkdtempSync(join(tmpdir(), 'strata-ds-args-q-'));
const install = join(root, 'install');
mkdirSync(install, { recursive: true });
process.env.STRATA_INSTALL_DIR = install;
const path = join(install, 'strata-q2_0.json');
writeFileSync(path, JSON.stringify({ exe: 'component:strata-engine', tokenizer: join(install, 'tok'), args: ['--pack', '/p', '--max-context', '4096', '--vision', '--kv', 'int8'], model_name: 'Qwen', port: 8080 }));
const saved = [];
const control = {
control: { dir: join(root, 'control') }, version: '0.2.7',
async installedModels() { return { install_dir: install, models: [{ id: 'q2_0', name: 'Qwen', source_config: path, component_status: 'present', context_tokens: 4096 }] }; },
async engine() { return { usable: true, backend: 'cuda', components_dir: 'E:\\c', engine: { version: '0.2.0', platform: 'windows-x86_64', backend: 'cuda', exe: 'E:\\e\\strata.exe', root: 'E:\\e' } }; },
async saveProfile(name, file) { saved.push(JSON.parse(readFileSync(file, 'utf8'))); return {}; },
async start() { return { ok: true, state: 'ready' }; },
async stop() { return { ok: true }; },
};
const native = createNativeControl({ control, hardwareProbe: () => [{ index: 0, vram_gb: 12 }], directory: join(root, 'data'), spawnProcess: () => { throw new Error('no process'); }, fetcher: async () => Response.json({ status: 'ok' }), requests: () => [] });
try {
await native.refresh(true);
const job = await finish(native, native.operation('load', { model: 'q2_0', vision: 'no' }).id);
assert.equal(job.state, 'done', job.error);
assert.deepEqual(saved[0].args, ['--pack', '/p', '--max-context', '4096', '--kv', 'int8']);
} finally { await native.close(); rmSync(root, { recursive: true, force: true }); }
});
+59 -3
View File
@@ -88,10 +88,26 @@ test('runCli passes the arguments untouched (no shell) and reads JSON', async ()
// ---------------------------------------------------------------------------------- native-control in packaged mode
function packagedFixture({ engineUsable = true, updater = null, repair = false, ids = ['iq2_xs', 'iq3_s'] } = {}) {
function packagedFixture({ engineUsable = true, updater = null, repair = false, ids = ['iq2_xs', 'iq3_s'], controlVersion = '0.1.45', extraSizes = [], cliStartWait = 'old' } = {}) {
const root = mkdtempSync(join(tmpdir(), 'strata-packaged-test-'));
const install = join(root, 'install'), data = join(root, 'data');
mkdirSync(install, { recursive: true });
/* The Control this app found, as it is on disk. The app reads the start-wait ceiling out of these files instead of trusting
the version, because a start wait is checked three times before the engine is reached and a released Control has those
three ceilings disagreeing:
'old' no runtime ceiling at all, both gates 600 (Control before #3949)
'split' the runtime takes 1800, the CLI and the record still stop at 600 (every Control 0.1.50 to 0.1.52)
'cli-only' the CLI follows the runtime, the record still stops at 600 (`direct runtime intent has an invalid timeout`)
'runtime' all three hand the wait to the runtime's own ceiling */
const controlSource = join(root, 'control', 'strata_control');
mkdirSync(controlSource, { recursive: true });
writeFileSync(join(controlSource, 'runtime.py'), cliStartWait === 'old' ? '' : 'MAX_START_WAIT = 1800\n');
writeFileSync(join(controlSource, 'management.py'), ['cli-only', 'runtime'].includes(cliStartWait)
? ' wait = _seconds(wait, 0, runtime.MAX_START_WAIT, "start wait")\n'
: ' wait = _seconds(wait, 0, 600, "start wait")\n');
writeFileSync(join(controlSource, 'journal.py'), cliStartWait === 'runtime'
? ' ranges = {"wait_s": (0, start_wait_max) if kind == "runtime.start" else\n'
: ' ranges = {"wait_s": (0, 600) if kind == "runtime.start" else\n');
const earlierInstallEnv = process.env.STRATA_INSTALL_DIR;
process.env.STRATA_INSTALL_DIR = install; // the app's own install folder (the host always tells Control which one; it never falls back to an older CLI folder)
const configs = ids.map(id => {
@@ -105,7 +121,7 @@ function packagedFixture({ engineUsable = true, updater = null, repair = false,
const rpc = [];
let startFailure = null, startFailureLeft = Infinity;
const control = {
control: { dir: join(root, 'control'), python: join(root, 'control', 'python', 'python.exe') }, version: '0.1.45',
control: { dir: join(root, 'control'), python: join(root, 'control', 'python', 'python.exe') }, version: controlVersion,
async installedModels(options) { calls.push(['installedModels', options?.installDir ?? null]); return { install_dir: install, models: configs.map(c => ({ id: c.id, name: `Qwen ${c.id}`, source_config: c.path, component_status: 'present', context_tokens: 32768, backend: 'cuda' })) }; },
async engine() {
calls.push(['engine']);
@@ -126,7 +142,7 @@ function packagedFixture({ engineUsable = true, updater = null, repair = false,
// answers like it: the catalog, the hardware with the GPUs, and an install plan or job.
const catalog = { families: [{ family: 'qwen', title: 'Qwen3.8-Flash-Next', about: 'x', images: true, experimental: false, sizes: [
{ id: 'q2_0', model: 'Q2_0', download_gb: 66.4, ram_needed_gb: 48, about: 'fast', on_this_pc: 'fits', installed: false, images: true, experimental: false },
{ id: 'iq3_xxs', model: 'IQ3_XXS', download_gb: 75.8, ram_needed_gb: 60, about: 'balanced', on_this_pc: 'fits', installed: false, images: true, experimental: false }] }],
{ id: 'iq3_xxs', model: 'IQ3_XXS', download_gb: 75.8, ram_needed_gb: 60, about: 'balanced', on_this_pc: 'fits', installed: false, images: true, experimental: false }, ...extraSizes] }],
contexts: [8192, 32768], recommendation: { family: 'qwen', model: 'IQ3_XXS', context: 32768 }, context_note: '' };
const hardware = { ram_gb: 63, cpu: { name: 'cpu', cores: 12 }, gpus: [{ index: 0, name: 'NVIDIA GeForce RTX 5070', vram_gb: 11.9, usable: true }] };
let installJob = null;
@@ -606,3 +622,43 @@ test("onboarding 'use your own GGUF' (#3507, #3424): the models folder the perso
assert.ok(sent.data_dir === undefined || sent.data_dir === join(f.root, 'Strata-data'), `data_dir was ${sent.data_dir}`);
} finally { await f.native.close(); f.cleanup(); }
});
/* The start wait follows the model's size, as `strata launch` does since #3949, and never asks an older Control for more than it takes.
Reported on the app side by #3459 and #4191: a big model on a slow disk was stopped at 300 s while the engine went on reading. */
test('packaged: a big model is waited for by its size, inside the ceiling of the Control this app has (#3459 #4191)', async () => {
const run = async (cliStartWait, downloadGb) => {
const f = packagedFixture({ ids: ['big'], cliStartWait, extraSizes: [{ id: 'big', model: 'BIG', download_gb: downloadGb, ram_needed_gb: 60, about: 'x', on_this_pc: 'fits', installed: false, images: false, experimental: false }] });
try {
await f.native.refresh(true);
const job = await finish(f.native, f.native.operation('load', { model: 'big' }).id);
assert.equal(job.state, 'done', job.error);
const start = f.calls.find(c => c[0] === 'start');
const saved = f.saved[0];
await f.native.close();
return { wait: start[2], ready: saved.engine_ready_s };
} finally { f.cleanup(); }
};
assert.equal((await run('old', 180)).wait, 600, 'a Control with no long-wait ceiling at all is asked for 600 s, as today');
assert.equal((await run('split', 180)).wait, 600, 'a Control whose runtime takes 1800 s but whose CLI still stops at 600 is asked for 600 s: the CLI would refuse the start outright (#3949)');
assert.equal((await run('cli-only', 180)).wait, 600, 'a Control whose CLI takes it but whose operation record still stops at 600 is asked for 600 s too: the record is written before the start runs');
const long = await run('runtime', 180);
assert.equal(long.wait, 1720, '600 s + 8 s per GB above 40 GB, as in strata launch, once the CLI really takes it');
assert.equal(long.ready, 1780, 'the server readies after the start gave up, never before it');
const small = await run('runtime', 20);
assert.equal(small.wait, 300, 'a model under 40 GB keeps the 300 s it always had');
assert.equal(small.ready, undefined, 'nothing is added to a small model’s run config');
const huge = await run('runtime', 400);
assert.equal(huge.wait, 1800, 'and never more than this Control accepts');
});
test('packaged: a run config that names a longer engine wait is honoured, and never shortened by the app', async () => {
const f = packagedFixture({ ids: ['deepseek-v4.1-flash-ist-das-2.00bpw'], cliStartWait: 'runtime' });
try {
writeFileSync(join(f.install, 'strata-deepseek-v4.1-flash-ist-das-2.00bpw.json'), JSON.stringify({ exe: join(f.install, 'engine-dsv41', 'strata-dsv41'), arch: 'deepseek41', tokenizer: join(f.install, 'tok'), args: ['--pack', join(f.install, 'pack')], model_name: 'deepseek-v4.1-flash', port: 8080, engine_ready_s: 3600 }));
await f.native.refresh(true);
const job = await finish(f.native, f.native.operation('load', { model: 'deepseek-v4.1-flash-ist-das-2.00bpw' }).id);
assert.equal(job.state, 'done', job.error);
assert.equal(f.calls.find(c => c[0] === 'start')[2], 1800, 'the wait of the newest Control, which is what the ceiling allows');
assert.equal(f.saved[0].engine_ready_s, 3600, 'setup’s own longer wait stays as it is');
} finally { await f.native.close(); f.cleanup(); }
});
+1 -1
View File
@@ -57,5 +57,5 @@ test('Cargo.toml, tauri.conf.json and the desktop package agree on the app versi
const cargo = /^version\s*=\s*"([^"]+)"/m.exec(await readFile(new URL('src-tauri/Cargo.toml', root), 'utf8'))[1];
const pkg = JSON.parse(await readFile(new URL('package.json', root), 'utf8')).version;
assert.equal(cargo, pkg);
assert.equal(cargo, '0.2.7');
assert.equal(cargo, '0.2.8');
});
+22 -10
View File
@@ -13,19 +13,19 @@ import { nativeFixture } from './helpers/native-fixture.mjs';
const read = rel => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8').replace(/\r\n/g, '\n');
const ENV = { // what each switch writes outside its default
faster_long_prompts: { STRATA_KVA: '1' },
faster_12gb_nvidia: { STRATA_ROUTE_PRIOR: '0' },
faster_12gb_nvidia: { STRATA_ROUTE_PRIOR: '1.5' },
faster_radeon_9000: { STRATA_PCIE_FRAC_DEFAULT: 'old' },
deepseek_speedups: { DS4_DEFAULTS: '026', DS4_FAST_ATTN: '0', DS4_ATTN_SLAB: '0', DS4_COPY_PIN: '0', DS4_COPY_HUGETLB: '0', DS4_STORE_SPARE: '0', DS4_STORE_PUT: '0' },
};
const SIX = ['DS4_FAST_ATTN', 'DS4_ATTN_SLAB', 'DS4_COPY_PIN', 'DS4_COPY_HUGETLB', 'DS4_STORE_SPARE', 'DS4_STORE_PUT'];
const DEFAULT = { faster_long_prompts: 'off', faster_12gb_nvidia: 'on', faster_radeon_9000: 'on', deepseek_speedups: 'on' };
const DEFAULT = { faster_long_prompts: 'off', faster_12gb_nvidia: 'off', faster_radeon_9000: 'on', deepseek_speedups: 'on' };
const RTX3060 = { index: 0, name: 'NVIDIA GeForce RTX 3060', vendor: 'nvidia', arch: '86', vram_gb: 12 };
const R9700 = { index: 0, name: 'AMD Radeon AI PRO R9700', vendor: 'amd', arch: 'gfx1201', vram_gb: 32 };
const RX7900 = { index: 0, name: 'AMD Radeon RX 7900 XTX', vendor: 'amd', arch: 'gfx1100', vram_gb: 24 };
const RTX = { index: 0, name: 'NVIDIA GeForce RTX 4090', vendor: 'nvidia', arch: '89', vram_gb: 24 };
const STRIX = { index: 0, name: 'AMD Radeon 8060S', vendor: 'amd', arch: 'gfx1151', vram_gb: 96, uma: true };
test('there are exactly four switches in the app (the Terminal has one more); each writes the engine names of the contract and has its default (KVA off, the others on)', () => {
test('there are exactly four switches in the app (the Terminal has one more); each writes the engine names of the contract and has its default (KVA and the 12 GB routing off, the other two on)', () => {
assert.deepEqual(SPEED_SWITCHES.map(s => s.id), Object.keys(ENV));
for (const sw of SPEED_SWITCHES) {
assert.deepEqual(sw.env, ENV[sw.id], sw.id);
@@ -38,7 +38,7 @@ test('the Terminal\'s Qwen "0.2.6 behaviour" switch is not in the app', () => {
assert.ok(!Object.values(SPEED_SWITCHES.flatMap(s => Object.keys(s.env))).includes('STRATA_DEFAULTS'));
});
test('Faster answers on 12 GB NVIDIA cards: only for Qwen on a discrete CUDA card of 13 GiB or less; Off writes STRATA_ROUTE_PRIOR=0', () => {
test('Faster answers on 12 GB NVIDIA cards: only for Qwen on a discrete CUDA card of 13 GiB or less; off by default since 0.2.8 (#4402), On writes STRATA_ROUTE_PRIOR=1.5', () => {
const ids = context => speedSwitchRows({}, context).map(r => r.id);
assert.deepEqual(ids({ backend: 'cuda', gpus: [RTX3060] }), ['faster_long_prompts', 'faster_12gb_nvidia']);
assert.deepEqual(ids({ backend: 'cuda', gpus: [{ ...RTX3060, vram_gb: 13 }] }), ['faster_long_prompts', 'faster_12gb_nvidia'], '13 GiB counts');
@@ -55,12 +55,24 @@ test('Faster answers on 12 GB NVIDIA cards: only for Qwen on a discrete CUDA car
assert.deepEqual(ids({ backend: 'sycl', gpus: [RTX3060] }), [], 'not a CUDA build');
assert.deepEqual(ids({ backend: 'cuda', gpus: [RTX3060], arch: 'qwen35moe' }), [], 'Qwen3.6-35B-A3B: the routing needs 512 experts');
assert.deepEqual(ids({ deepseek: true, backend: 'cuda', gpus: [RTX3060] }), ['deepseek_speedups']);
const off = setSpeedSwitch({ PATH: '/bin' }, 'faster_12gb_nvidia', false);
assert.deepEqual(off, { PATH: '/bin', STRATA_ROUTE_PRIOR: '0' });
assert.equal(speedSwitchIsOn(off, 'faster_12gb_nvidia'), false);
assert.equal(speedSwitchIsOn({}, 'faster_12gb_nvidia'), true);
assert.deepEqual(setSpeedSwitch(off, 'faster_12gb_nvidia', true), { PATH: '/bin' });
assert.equal(speedSwitchIsOn({ STRATA_ROUTE_PRIOR: '2.0' }, 'faster_12gb_nvidia'), true, 'any other value is the user\'s own and reads as the default');
// a default launch carries nothing: the engine then runs as 0.2.6 did (STRATA_ROUTE_PRIOR unset = 0)
assert.equal(speedSwitchIsOn({}, 'faster_12gb_nvidia'), false);
assert.equal(speedSwitchRows({}, { backend: 'cuda', gpus: [RTX3060] }).find(r => r.id === 'faster_12gb_nvidia').on, false);
assert.deepEqual(setSpeedSwitch({ PATH: '/bin' }, 'faster_12gb_nvidia', false), { PATH: '/bin' }, 'Off is the default: it writes nothing');
// the explicit opt-in the row writes
const on = setSpeedSwitch({ PATH: '/bin' }, 'faster_12gb_nvidia', true);
assert.deepEqual(on, { PATH: '/bin', STRATA_ROUTE_PRIOR: '1.5' });
assert.equal(speedSwitchIsOn(on, 'faster_12gb_nvidia'), true);
assert.deepEqual(setSpeedSwitch(on, 'faster_12gb_nvidia', false), { PATH: '/bin' }, 'back to Off removes the entry');
// the 0.2.7 "Off" wrote STRATA_ROUTE_PRIOR=0: that file is in the default state now, and On replaces it with 1.5
assert.equal(speedSwitchIsOn({ STRATA_ROUTE_PRIOR: '0' }, 'faster_12gb_nvidia'), false);
assert.deepEqual(setSpeedSwitch({ STRATA_ROUTE_PRIOR: '0' }, 'faster_12gb_nvidia', true), { STRATA_ROUTE_PRIOR: '1.5' });
assert.deepEqual(setSpeedSwitch({ STRATA_ROUTE_PRIOR: '0' }, 'faster_12gb_nvidia', false), {}, 'and Off clears it');
// the words say both sides
const hint = SPEED_SWITCHES.find(s => s.id === 'faster_12gb_nvidia').hint;
assert.match(hint, /faster on 12 GB cards/);
assert.match(hint, /Off by default/);
assert.match(hint, /long agent sessions it can repeat itself/);
});
test('DeepSeek speed-ups Off writes DS4_DEFAULTS=026 and the six; On removes all seven; the six alone (an earlier test build) still read as Off', () => {
+41
View File
@@ -45,3 +45,44 @@ test('counter lines are read, and the load and VRAM in use reach the card by its
assert.equal(reader.read('luid_0x00000000_0x00099999'), null);
assert.equal(procs.length, 1, 'one process for every read');
});
// ---- 0.2.8: the card's temperature from the display driver (D3DKMT), #3727 ----
import { d3dkmtTemperatureC, GPU_TEMP_CSHARP } from '../server/windows-gpu.mjs';
test('temperature comes in tenths of a degree; no answer, zero and nonsense are not a reading', () => {
assert.equal(d3dkmtTemperatureC(300), 30);
assert.equal(d3dkmtTemperatureC(437), 44);
assert.equal(d3dkmtTemperatureC(-1), null);
assert.equal(d3dkmtTemperatureC(0), null);
assert.equal(d3dkmtTemperatureC(4294967295), null);
assert.equal(d3dkmtTemperatureC(undefined), null);
assert.equal(d3dkmtTemperatureC('abc'), null);
});
test('a counter line carries the temperature; the older three-field line and a failed query still parse', () => {
assert.equal(parseGpuCounterLine('G luid_0x00000000_0x00016102 37 4294967296 430').temperatureC, 43);
assert.equal(parseGpuCounterLine('G luid_0x00000000_0x00016102 37 4294967296 -1').temperatureC, null);
const old = parseGpuCounterLine('G luid_0x00000000_0x00016102 37 4294967296');
assert.equal(old.temperatureC, null); assert.equal(old.utilizationPct, 37);
});
test('the temperature reaches the reader by LUID next to load and memory', () => {
const procs = [];
const reader = createWindowsGpuReader({ spawnFn: () => { const p = new EventEmitter(); p.stdout = new EventEmitter(); p.kill = () => {}; procs.push(p); return p; }, now: () => 5 });
reader.read('luid_0x00000000_0x00016102');
procs[0].stdout.emit('data', 'G luid_0x00000000_0x00016102 90 17000000000 465\n');
const r = reader.read('luid_0x00000000_0x00016102');
assert.equal(r.temperatureC, 47); assert.equal(r.utilizationPct, 90);
});
test('the PowerShell script that asks D3DKMT is single-quote safe and uses adapter performance data type 62 with the temperature at byte 56', () => {
assert.ok(!GPU_TEMP_CSHARP.includes("'"), 'it sits inside a single-quoted PowerShell string');
assert.match(GPU_TEMP_CSHARP, /i\.T=62/);
assert.match(GPU_TEMP_CSHARP, /ReadInt32\(buf,56\)/);
assert.match(GPU_TEMP_CSHARP, /AllocHGlobal\(64\)/);
let script = '';
createWindowsGpuReader({ spawnFn: (_cmd, args) => { script = args.at(-1); const p = new EventEmitter(); p.stdout = new EventEmitter(); p.kill = () => {}; return p; } }).read('x');
assert.ok(script.includes('Add-Type -TypeDefinition'), 'compiled once, before the loop');
assert.ok(script.indexOf('Add-Type') < script.indexOf('while($true)'));
assert.match(script, /try\{Add-Type[^]*\}catch\{\}/, 'a failed compile must not stop load and memory');
});
+2 -2
View File
@@ -10,8 +10,8 @@ holds the technical context behind the short [README](../README.md). Use [AI_SET
- Strata runs supported language models locally, with a desktop app, a terminal app, and an inference engine.
- The engine uses the GPU, system RAM and, for applicable models and memory modes, SSD storage.
See [how it works](HOW_IT_WORKS.md) and [engine details](DETAILS.md).
- There is one app installer per system (`Strata-0.2.7-windows-x64-setup.exe`, `Strata-0.2.7-linux-x86_64.AppImage`,
`Strata-0.2.7-macos-arm64.dmg`). The Windows and Linux installers carry no engine: the app downloads the engine for the
- There is one app installer per system (`Strata-0.2.8-windows-x64-setup.exe`, `Strata-0.2.8-linux-x86_64.AppImage`,
`Strata-0.2.8-macos-arm64.dmg`). The Windows and Linux installers carry no engine: the app downloads the engine for the
graphics card on first start, or installs a file the user already has. The Mac dmg keeps its Metal engine inside.
The Terminal's `strata launch` downloads the appropriate engine and model.
See [installation](INSTALL.md) and [Terminal launch](TERMINAL_LAUNCH.md).
+2 -2
View File
@@ -4,8 +4,8 @@ Use this guide when a user asks an AI agent to install Strata (version 0.2.5). T
one; do not start from a source checkout.
- **The Terminal and `strata launch`** (below). This is the route for an agent that has a shell.
- **The desktop app.** One installer per system: `Strata-0.2.7-windows-x64-setup.exe`,
`Strata-0.2.7-linux-x86_64.AppImage`, `Strata-0.2.7-macos-arm64.dmg`, from the
- **The desktop app.** One installer per system: `Strata-0.2.8-windows-x64-setup.exe`,
`Strata-0.2.8-linux-x86_64.AppImage`, `Strata-0.2.8-macos-arm64.dmg`, from the
[latest release](https://github.com/Niko1221/Strata/releases/latest). The Windows and Linux installers carry no
engine: on first start the app shows the engine for the user's graphics card and downloads it, or installs a file the
user already has ("I already have the file"). Details: [INSTALL.md](INSTALL.md).
+2 -2
View File
@@ -49,7 +49,7 @@ carries it but it is untested. Details: [MODELS.md](MODELS.md#deepseek-v41-flash
## Ready-made packages
The Terminal one-liner and `strata update` pick the package whose GPU code covers the card. The app installer
(`Strata-0.2.7-windows-x64-setup.exe`, `Strata-0.2.7-linux-x86_64.AppImage`) has no engine inside: on its first start the app
(`Strata-0.2.8-windows-x64-setup.exe`, `Strata-0.2.8-linux-x86_64.AppImage`) has no engine inside: on its first start the app
downloads the matching package for the card. You can also press **Update engine** in Settings (or run `strata update`).
| Package (variant) | OS | GPU code inside | ROCm |
@@ -131,7 +131,7 @@ reused. The Monitor shows the card's load, VRAM, temperature and power from Linu
## Windows
Install the [app](INSTALL.md) (`Strata-0.2.7-windows-x64-setup.exe` from the
Install the [app](INSTALL.md) (`Strata-0.2.8-windows-x64-setup.exe` from the
[release page](https://github.com/Niko1221/Strata/releases/latest); it downloads the engine for the card), or
install the [Terminal](INSTALL.md#the-terminal) and run `strata launch`. You need Windows 10 or 11 (64-bit), a supported card
and a current AMD driver. No ROCm or HIP SDK, no compiler, no admin rights. `START-HERE.bat --backend hip` is a
+62 -3
View File
@@ -87,13 +87,13 @@ With a layer split, the engine options go into the config's `args`:
| Option | What it does |
| --- | --- |
| `"parallel": N` / `--batch N` / `--slots N` (2..8 normally; with a layer split up to 8 per group, see below) | up to N conversations have batch slots; more requests wait for a free slot. Each slot gets its own state (a session carved like the stage's own: GDN recurrence, QSA K/V and indexer, PLE history) on every GPU of the split. With grouped MTP, more than 8 slots can rotate through eight-row windows if memory permits. |
| `"parallel": N` / `--batch N` / `--slots N` (2..8 normally; with a layer split up to 8 per group, 16 in a HIP build, see below) | up to N conversations have batch slots; more requests wait for a free slot. Each slot gets its own state (a session carved like the stage's own: GDN recurrence, QSA K/V and indexer, PLE history) on every GPU of the split. With grouped MTP, more than 8 slots can rotate through eight-row windows if memory permits. |
| `--batch-groups G` | with a layer split (default: auto, below): the N slots in G groups that flow through the GPUs as a pipeline (GPU k runs one group while GPU k+1 runs another). G must divide N. 1 = all slots in one window, GPU after GPU. |
| `--batch-groups auto` | the default on a layer split since 0.1.41 (give no `--batch-groups`): the engine pipelines one group per GPU stage (the most that divide the slots; 8 slots on 4 GPUs = 4 groups of 2) and says so (`INFO batch_groups=G`). `--batch-groups 1` turns it off (all slots in one window, GPU after GPU). Measured, 8 clients, total tok/s against one group: 4 x R9700 166 against 86, 2 GPUs 109 against 78, 3 GPUs 110 against 78. With `--adapt-async 1` and no `--batch-groups` the default is one group (the adaptive tier between batch windows does not run in pipelined groups) and the log says so; with `--batch-mtp` and no `--batch-groups` the pipeline wins and `--batch-mtp` is left off (`--batch-groups 1` or `STRATA_BATCH_MTP_SERIAL=1` keeps it); `--batch-groups G` or `--batch-groups auto` given with `--batch-mtp` pipelines the groups and turns `--batch-mtp` off, and given with `--adapt-async 1` pipelines the groups and turns `--adapt-async` off (the pipelined path does not adapt). | PROPOSED for 0.2.1 (owner decides; branch f-021-split): on NVIDIA builds with exactly two stages the default is one group instead (2x RTX A4000, IQ3_XXS, aggregate tok/s against the pipelined default: +16% at 2 slots, +12% at 4, +15% at 8, 5 of 5 pairs each; `--batch-groups auto` or a number asks for the pipeline, `STRATA_BATCH_GROUPS_NV_SERIAL=0` restores the old default).
| `--trim-stage-weights` | with an **explicit** `--layer-split` (e.g. `12,24,36`, not `auto`): every GPU loads only the dense weights of its own layers instead of the whole model's (the same as `STRATA_STAGE_TRIM=1`, PR #639). The VRAM this frees goes to the expert cache. Useful without `--batch` too. |
The engine never refuses a count it cannot run: it says so in its log and runs what it can - at most 8 slots without a
layer split (a window holds 8 rows; with a layer split and pipelined groups, 8 per group), as many as fit in VRAM, or none (one request at a time) when not two fit. The server reads
layer split (a window holds 8 rows; with a layer split and pipelined groups, 8 per group, 16 in a HIP build: see "Windows of 16 rows" below), as many as fit in VRAM, or none (one request at a time) when not two fit. The server reads
the count the engine reports (`INFO batch_slots=N`), and `GET /v1/status` says it (`concurrency.serving`).
## More than 8 slots on a layer split, and the opt-in admission and decode switches
@@ -103,7 +103,7 @@ the count the engine reports (`INFO batch_slots=N`), and `GET /v1/status` says i
**More than 8 slots.** With a layer split a pipelined window holds at most 8 rows, but each group of the pipeline is its
own window, so `--batch 16` on 4 GPUs runs 4 groups of 4 slots (`--batch-groups auto`), `--batch 32` runs 4 groups of 8.
Until 0.1.44.1 the engine cut such a request down to 8 slots and said so; it now runs what is asked for, as many slots
as fit in VRAM and at most 8 per group (`INFO batch_slots=N`). `--batch-mtp` keeps its limit (rows per window are
as fit in VRAM and at most 8 per group, 16 in a HIP build (`INFO batch_slots=N`; "Windows of 16 rows" below). `--batch-mtp` keeps its limit (rows per window are
doubled by the proposals). Nothing changes for 8 slots or fewer. Every slot of a 16-slot pipelined batch decodes
exactly the tokens it decodes alone (`tools/batch_test.py --n 16`, which now takes up to 32 prompts).
@@ -536,6 +536,65 @@ run without it; inside one run every slot still equals its own solo decode.
Not run: AMD (HIP), the draft layer on the first stage, helper expert caches, more than two stages, SYCL (the SYCL
tree has its own copy of the batch code and does not have this).
## Windows of 16 rows, and the four-card AMD profile
A verify or batch window held at most 8 rows. On a layer split every group of the pipeline is its own window, so 32 slots ran as
4 groups of 8 and nothing larger could run. HIP builds now take windows of 16 rows: `--batch 64` on four cards is 4 groups of
16, and `--batch 16 --batch-groups 1` is one 16-row window over all stages. The limit is a build option
(`-DSTRATA_WINDOW_MAX_T=auto|8|16`, default `auto`):
| build | window | why |
|---|---|---|
| HIP (AMD) | 16 | the code objects load when a kernel is first used: the bigger binary (44.9 to 53.7 MB) costs +7 MiB per card after the first request and about 30 to 45 ms once per server start |
| CUDA | 8 | CUDA loads every kernel when the engine starts: 16 adds 14 MB of kernels and +16 MiB of VRAM (RTX PRO 5000) and nothing is gained below 9 rows. On a 16 GB card those MiB shrink the auto-sized expert cache (2 x A4000: 5292 slots instead of 5301), and a cached expert rounds differently from a CPU one, so the texts differ from the 8-row build by that alone (main run with a 17 MiB larger `--vram-reserve-mib` gives the 16-row build's texts, 10 of 10). `-DSTRATA_WINDOW_MAX_T=16` builds it |
| SYCL, Metal | 8 | not touched (they build the shared sources with the limit left at 8) |
A window of up to 8 rows runs exactly the kernels of 0.2.7; the code for 9 to 16 rows is only reached by a wider window. Checked
instruction by instruction: HIP gfx1201, window 8 against the previous main, 3096 of 3097 kernels identical (the other is a
`STRATA_DBG_GDN` debug variant) and 5 renamed; CUDA sm_86 SASS, 3393 of 3397 identical or renamed, the other four differ in the
order of the same instructions. The CPU expert kernels keep 8 tokens per job (a group of 9 to 16 rows is two jobs over the same
expert), and the hyper-connection read is made in slices of 8 rows.
Exactness, `tools/batch_test.py` (greedy tokens of every slot against the same prompt alone): 4 x R9700, 64 slots in 4 groups of 16
(average 13.7 rows a window), 64 of 64 identical, with and without the three switches below; one R9700, `--batch 16`, 16 of 16;
4 x R9700, `--batch 16 --batch-groups 1` (one 16-row window), 16 of 16; 32 slots in 4 groups of 8 with the profile below, 32 of 32.
(Note: `--batch 16` on four stages is 4 groups of 4 slots, windows of 4 rows; it does not test 16-row windows.) Identity
(`rules-144` flags, 10 prompts and a 4K and a 20K prompt, residual dumps compared): p620 (IQ3_S and IQ3_XXS), r9700, l620 and boston (CUDA, window 8 and 16), z440 and p1004 (CUDA, window 8) identical to main
(z440 with window 16: see the CUDA row above).
### Four Radeon AI PRO R9700 cards: the serving profile
The four cards hold the whole of Qwen3.8-Flash-Next IQ3_S, so what limits them is how many requests decode together and how the
four stages are fed. Measured through `serve/server.py` with the launcher's own configuration (400-token answers, short
prompts, total tokens per second of the second round; 7.9K-token prompts for the long row):
| | 1 request | 8 at once | 32 at once | 4 long prompts, out / in tok/s | follow-up TTFT |
|---|---:|---:|---:|---:|---:|
| before: `parallel` 4, split auto | 66.6 | 156.6 | 155.4 | 24.3 / 1892 | 0.78 s |
| profile (below) | 67.9 | 261.2 | 369.6 | 35.0 / 2706 | 0.77 s |
The profile, filled in by `setup.py` and by the launcher only for four cards of that kind (R9700, at least 28 GB each, no
`--peer-device`) running Qwen3.8 IQ3_S:
- `parallel` 32 and `--batch-groups 4`: 32 slots, one group of 8 per card;
- layer split `13,26,39`: the output head sits on the last card and costs about four layers, so it takes 9 and the others 13;
- `--prefill 2048` instead of `auto`;
- `STRATA_QSA_SLOTS=1`, `STRATA_BATCH_SELF_COMMIT=1`, `STRATA_SLOT_COPY_DIRECT=1` (switches from the table above).
What each part gives at 32 at once (tok/s, against 398.7 for the profile with the prompt cache off, which is the owner's profile B): without the three switches 354.3,
split `auto` 374.6, 8 slots instead of 32 277. `--prefill auto` gives the same throughput but a longer first token for a 4.7K prompt
(2.06 against 1.52 s). 64 slots give 418.9 at 64 clients against 398.4 for 32 slots (+5%, measured at an 8K context only) and were left out; `GPU_MAX_HW_QUEUES=4` changes nothing. The prompt cache stays on: turning it off was +8% at 32 clients
with new prompts, but a follow-up turn of a conversation then waits 1.5 s for its first token instead of 0.8 s, and agent loops
are mostly follow-up turns.
A setting of the person always wins: the profile fills only what is not set (the config, `--parallel`, `--layer-split`,
`--batch`, `--prefill` other than `auto`, an environment variable). `STRATA_FOUR_CARD_PROFILE=0`, or `"four_card_profile": false`
in the run config, turns it off. 25 minutes of 32 clients (1088 requests of 600 tokens) through the launcher's configuration ran
without an error at a steady 435 tok/s and with no change in VRAM.
Not done: the hand-off of a batch window between stages (`§7f` of the parallelization report, one flag a group) is not part of
this; it can emit a wrong token from time to time and is listed for later.
## Exactness
A batch row's arithmetic is the single-token window's, so with greedy decoding **every conversation of a batch
+98
View File
@@ -0,0 +1,98 @@
# DeepSeek-V4.1-Flash on a Mac
DeepSeek-V4.1-Flash runs on Apple Silicon Macs on its own Metal engine, `strata-dsv41`. It is a separate download from the
Qwen engine: the app and `strata launch` fetch it the first time you pick DeepSeek. This page lists what it needs, what it did
on the one Mac it was measured on, and what it cannot do yet.
> **Short version:** a Mac with 64 GB of memory or more, macOS 15 or newer, and 260 to 280 GB of free disk on a fast drive.
> Text only, no pictures. The model does not fit in memory, so part of it is read from the SSD for every word. On an M5 Max with
> 128 GB, DeepSeek 2.00 answers a short question at 15 to 18 tokens a second and reads a 3,000-token prompt in about 11 seconds.
## What it needs
| | |
|---|---|
| Mac | Apple Silicon (M1 or newer), macOS 15 or newer. Measured on an M5 Max with 128 GB. |
| Memory | 64 GB or more. A Mac with less still starts, but most of the model is read from the SSD for every word, so it is much slower. Setup says so and asks before it goes on. |
| Disk | 2.00 set: 257 GB. 2.25 set: 274 GB (the 111 GB n-gram table is shared). Use the internal SSD or an NVMe drive, not a USB or network drive. |
| Pictures | Off. DeepSeek's picture reader was not run on the Metal engine, so a Mac reads text only. Qwen on the Mac keeps its pictures. |
## Install it
In the app: Models, DeepSeek-V4.1-Flash, pick 2.00 bpw (the default) or 2.25 bpw, Download and prepare.
In the Terminal:
```sh
strata launch --family deepseek41 --model ist-das-2.00bpw --plan # what it would do on this Mac
strata launch --family deepseek41 --model ist-das-2.00bpw
```
Setup downloads the set from Hugging Face (pinned revision, size and SHA-256 checked, resumable), builds the pack (a few MB),
downloads the DeepSeek engine package for macOS from the signed update feed, and runs a short test question before it starts.
Files you already have: `--model-path DIR`.
## What it did (M5 Max, 128 GB, 0.2.7.1 build)
Speeds are from the packaged engine with the shipped defaults (two Engram reader threads), one fresh process per run, two runs each. They were
measured while another person's Qwen server (a 60 GB model, loaded and idle) was running on the same Mac, so the file cache that
serves the streamed experts was shared with it. A Mac with nothing else running may be faster; that was not measured.
| | 2.00 bpw | 2.25 bpw |
|---|---|---|
| Short answer, code prompt | 15.2 to 16.0 tokens/s | 14.7 to 15.3 |
| Short answer, agent prompt | 15.3 to 16.2 | 13.1 to 13.6 |
| After a 3,000-token prompt | 14.5 to 15.1 | 11.7 to 11.8 |
| After a 14,000-token prompt | 13.8 to 15.1 | 10.5 to 11.1 |
| 3,000-token prompt | 236 to 241 tokens/s | 293 to 294 |
| 14,000-token prompt | 479 to 512 | 553 to 586 |
| 32,000-token prompt | 609 | 720 |
| Read from the SSD per word | 48 MB | 111 MB |
| Quality against the original model (KL, 4,995 positions) | 0.170 | 0.111 |
The 2.25 set is the better of the two (about 8% above the original model's perplexity on the Mac's scoring run, against about 17%). The 2.00 set is
faster and smaller. The quality numbers come from the engine's scoring run with a fixed expert placement
(`--profile-readonly`); a run that learns the placement as it goes moves the last digits.
How the memory is used: the engine keeps about 68 GB of the experts in memory on a 128 GB Mac. That share follows from the
Mac's memory (the RAM less 40% and the engine's own footprint), not from what happens to be free at the start. The rest of the memory goes to
macOS and its file cache, and a Mac with less memory keeps fewer experts. The remaining experts are read from the SSD in place.
A decode token is limited by those SSD reads and by a CPU lane that computes the experts the GPU does not hold, not by the GPU.
If another program already holds a lot of memory (a browser, another model's server), the engine keeps fewer experts so that
macOS does not run out. That costs some speed and keeps the Mac responsive.
The needle test (a code hidden in a long text, asked for at the end) finds it at 14,600, 17,000, 19,500 and 26,200 tokens.
## Speed setting
DeepSeek's Speed choice (Faster = 5 experts a word, Full quality = 6) works as on a PC. On a Mac the engine's own default is
Full quality. That is what was measured and scored.
## Limits
- Text only. A picture sent to a DeepSeek model on a Mac is refused.
- The drafter (DSpark) is off. It gave the same answers but no speed-up on the Mac.
- Context longer than about 32,000 tokens was not measured on a Mac.
- Smaller Macs: nothing under 128 GB was run with DeepSeek. The plan screen shows how much of the model your Mac keeps in memory
and warns when that is under a quarter of it. That estimate follows the engine's own rule and is not a measurement.
- The 1.75 bpw set shares the 2.00 set's kernel but was not run on a Mac, so it is not offered there.
- On the Mac, DeepSeek reads its Engram rows with two threads, because more threads gave slightly different words from run to
run (in 3 to 9 of 16 runs; the cause is being studied). This costs about 3 to 12% of prompt speed (3,000 tokens about 12%,
14,000 tokens 7 to 12%, 32,000 tokens about 3%); answer speed is the same. `DS4_ENGRAM_THREADS=N` sets another number.
- Two runs of the same prompt can differ in the last digits of a score, because the engine moves experts between memory and the
SSD as it learns what the prompt needs. The answers stay equally good.
## Building and packaging the engine (developers)
```sh
bash packaging/mac/build_dsv41_engine.sh # configure, build, stage, package and verify the macOS package
```
It needs the Xcode Command Line Tools, cmake and ninja (a pip venv is enough), Apple's metal-cpp and the llama.cpp tree that
setup pins (`METAL_CPP_DIR`, `STRATA_GGML_DIR`). The result is `pkg/engine/<version>-macos-arm64-metal/` with `strata-dsv41`,
the Metal sources it compiles at start-up and `engine-component.json`. The package is unsigned; the feed key signs it
(`packaging/UPDATES.md`). The Qwen Metal engine is not rebuilt by this: the DeepSeek runtime rules sit behind
`STRATA_DS4_RUNTIME` and are compiled only into `strata_ds4`.
Diagnostics, all off by default: `DS4_CPU_LOG=1` (per-token phase times), `DS4_PROF=1` (per-kernel GPU time),
`DS4_GPU_TOTAL=1`, `DS4_ENC_TIMES=1`, `DS4_TLOG=1`, `DS4_TRACE_DISP=1`.
+23 -4
View File
@@ -472,7 +472,7 @@ applies at the next start. Network, key, MCP and program keys are not editable t
| `repeat_stop_tokens` | `256` | A reply that repeats one token this many times in a row ends as `"length"`. `0` turns it off. |
| `effort_position` | `"start"` | `"end"` puts a `low` or `medium` effort in a short system turn right before the answer, so a request that only changes the effort reuses the conversation cache. How well the model follows a level in that place is not measured. |
| `anthropic_thinking` | as the template | `"on_request"` renders a `/v1/messages` request that asks for no thinking without thinking, as Anthropic does. Claude Code's short helper calls need it. |
| `tool_call_recovery` | off | Also reads a declared tool's call written as `<parameter=NAME>`, as JSON inside `<tool_call>`, as a `<function=NAME>` block without `<tool_call>`, or as a second call inside one `<tool_call>`. On 1,462 agent turns 98.3% read as intended with it and 92.9% without. |
| `tool_call_recovery` | on (since 0.2.8; off in 0.2.7 and before) | Also reads a declared tool's call written as `<parameter=NAME>`, as JSON inside `<tool_call>`, as a `<function=NAME>` block without `<tool_call>`, or as a second call inside one `<tool_call>`. On 1,462 agent turns 98.3% read as intended with it and 92.9% without; the 49 specimens of the test corpus read the same with it on or off. Only a declared tool's call is ever recovered; anything else stays text. `"tool_call_recovery": false` turns it off. |
| `env_to_user` | off | Moves opencode's `<env>` block (working directory, date) to the first user message so the system turn is the same for every session and the disk prefix cache restores it. |
| `return_progress` | off | Per request `"return_progress": true` adds llama.cpp's `prompt_progress` (`total`, `cache`, `processed`, `time_ms`) to the stream, one update per `--prefill` chunk. A prompt shorter than one chunk sends nothing. |
| `prevent_sleep` | off | Windows only: keeps the PC awake while a request runs or waits (`SetThreadExecutionState`). On Linux and macOS one startup line says it does nothing. |
@@ -501,6 +501,16 @@ applies at the next start. Network, key, MCP and program keys are not editable t
guessed: `bash` is not `shell`, and a reordered name (`server1_label_values` for `label_values_mcp_server1`) is not the declared one.
A call whose envelope drifted by a token (`<=NAME>`,
`<function:NAME>` or `<NAME>` instead of `<function=NAME>`) is read as the call when NAME is a declared tool.
- **What the parser repairs, and what it leaves.** A string argument that quotes a whole tool call, or repeats its own
`</parameter><parameter=NAME>`, stays exact (#4078). A call whose `</function>` was left out is read as the call (#4427), in the
answer and inside the thinking. An argument the tool declares as an array or an object, or declares nothing for, is read as
written when it has a slip in it: a trailing or doubled comma, single quotes or `True`/`False`/`None`, a bare string between two
members of an object (`"status": "pending", "in_progress", "pending": false`), brackets left open at the end. The server window
says `N tool call argument(s) repaired` and `/metrics/prometheus` counts them in `strata:tool_args_repaired_total`. Text that
is not an array or an object, a string cut short and a string-typed parameter are never touched. After the thinking has ended at
a closing think tag, a second one at the start of a line in the answer is dropped (the model quoted the tag while thinking, #4077):
the call that follows is kept. The reasoning between the two went out as the answer already, because a stream cannot take text
back.
- **A call that came back as text because its name was not declared (#3796).** The text stays exactly as the model wrote it and
`finish_reason` stays `stop` (or `end_turn` on `/v1/messages`). Next to it the reply carries a list, only when there is something in it:
`"strata_rejected_tool_calls": [{"name": "bash", "reason": "undeclared"}]` (at the top level of a Chat Completions reply, in
@@ -698,6 +708,15 @@ the engine (v2 `memory.max` and `memory.high`, v1 `memory.limit_in_bytes`, less
`--memory-limit-mib` cap. The startup line `strata generate: memory guard: limit X GiB (source: flag|cgroup|meminfo) ...` names
the source. It covers parking and session files only, not the startup sizing of the arena, the resident budget or the Windows commit check.
**When the arena does not fit in the RAM that is free (#4051).** The engine checks the RAM before it reads the experts into the
arena. A memory limit below the arena (a container) stops the start with the numbers. Less free RAM than the arena (an engine
that was still exiting, another program) is waited for up to 20 s, and the log says so (`STRATA_ARENA_RAM_WAIT_S` changes the
seconds, `0` loads at once); if it is still short the load goes on with a warning that says how much, never a refusal. Below half
of the arena the warning adds that the whole PC can stall in memory reclaim, and that a systemd `MemoryHigh=` below the RAM keeps
one program from taking all of it. The server, before it starts an engine, ends one that an earlier run left behind and now waits up
to 120 s for it to be gone (it used to wait 10 s): an engine with tens of GB of pinned memory needs longer to give it back, and the
next one started beside it ran the PC out of RAM (#4051).
Snapshots hold running state, checkpoints, used K/V pages and draft-layer K/V. They add host RAM, not VRAM. Oldest parked entries
are evicted first. A new conversation that starts at a checkpoint inside a parked one (sub-agents that share a system prompt)
restores that prefix and leaves the parked conversation in place (the log says "borrowed"). Oversized snapshots or host
@@ -1019,7 +1038,7 @@ them on your own card.
| `STRATA_PREFILL_EQUAL=1` | CUDA | A prompt segment is read in chunks of equal size, not full chunks and a short last one. Changes rounding. | |
| `STRATA_OWNED_PRICE=exact` | CUDA | The cache sizing prices the prompt path's own buffers by their real allocation. | |
| `STRATA_IQ_MT_MIN=1`, `STRATA_IQ3S_MT1=1` | CPU | See [Draft layer](#draft-layer) (reproducible output; the second gives IQ3_S the multi-token kernel for one token on Alder Lake and later without AVX-512). | |
| `STRATA_ROUTE_PRIOR`, `STRATA_ROUTE_RESIDENT`, `STRATA_ROUTE_RESIDENT_MTP` | CUDA | Routing changes that add speed and also add KL. | Not recommended, except that 0.2.7 turns `STRATA_ROUTE_PRIOR=1.5` on by itself on a discrete NVIDIA card of 13 GiB or less (the owner approved the quality trade for 12 GB cards; the first 0.2.7 candidates used 2.0, see the 12 GB default in the release notes for why it is 1.5); `STRATA_ROUTE_PRIOR=0` turns it off and an explicit value wins. It is never a default on a Radeon, an integrated GPU or a bigger card. The 1.5 default was checked on an RTX 3060 12 GB (IQ3_XXS) with 80 tasks: no copy or code errors seen; agent loops may finish slightly less often, within the run-to-run spread (out of 10 agent tasks 4.5 passed on average over 4 runs at 1.5, against 5.6 over 7 runs with the prior off, where the off runs alone ranged from 4 to 6). Speed against off: story +15%, code +37%. Off: the Expert-settings row "Faster answers on 12 GB NVIDIA cards" or `STRATA_ROUTE_PRIOR=0`. |
| `STRATA_ROUTE_PRIOR`, `STRATA_ROUTE_RESIDENT`, `STRATA_ROUTE_RESIDENT_MTP` | CUDA | Routing changes that add speed and also add KL. | Off by default. `STRATA_ROUTE_PRIOR=1.5` makes the router prefer experts that are already on the card; it is the one worth trying, on a discrete NVIDIA card of 13 GiB or less (an RTX 3060 12 GB, IQ3_XXS: story +15%, code +37% decode against off). It is an opt-in because it can repeat itself and give weaker results in long agent sessions (#4402; our own check on that card: out of 10 agent tasks 4.5 passed on average over 4 runs at 1.5, against 5.6 over 7 runs with the prior off, where the off runs alone ranged from 4 to 6; a stronger setting, 2.0, showed copy and code defects). Recommended for a 12 GB card used mostly for chat and short answers, not for long agent runs. 0.2.7 turned 1.5 on by itself on those cards and 0.2.8 took that back (owner decision of 2026-10-29): with nothing set, the engine routes exactly as 0.2.6 did (the same as `STRATA_ROUTE_PRIOR=0` and as `STRATA_DEFAULTS=026`). Turn it on: the Expert-settings row "Faster answers on 12 GB NVIDIA cards" (it writes `STRATA_ROUTE_PRIOR=1.5`; shown only for a discrete NVIDIA card of 13 GiB or less), `strata launch --set STRATA_ROUTE_PRIOR=1.5`, or the variable itself. Any value you set wins on every card; it is never on by itself. |
| `STRATA_HOST_CORE=last\|sibling` (or `--host-core`) | Windows | `last` puts the host thread on the last physical core, away from the core that gets the GPU's interrupts. `sibling` keeps it on the first core's other hardware thread, so the workers keep every core. The default `first` leaves a hybrid CPU as it is. | One layer-split box went from a ±6% swing to steady and 8% faster. |
| `--pool-affinity measure` | Hybrid CPUs, native packs | Times the `all` and `p-cores` layouts at start (about a second) and takes the faster. The measurement is taken once; where the E-cores' speed changes while the engine runs, `--pool-affinity p-cores` is the steady choice. | |
| `STRATA_KEEP_EMPTY_TURNS=1`, `STRATA_TOPK_STREAM=0` | | See [TROUBLESHOOTING.md](TROUBLESHOOTING.md). | |
@@ -1222,7 +1241,7 @@ On 12.0 cards (sm_120) these also apply:
prompt time and start-up time, which is why smaller PCs keep the SSD reads.
A prompt chunk above 8192 (`--prefill auto:16384` or `auto:32768`) is not bit-exact on CUDA, so the default stays 8192 on
NVIDIA. It is exact on gfx12, where 16384 is the default. `STRATA_ROUTE_PRIOR` raises KL, so below 0.2.7 it was opt-in; 0.2.7 sets 1.5 by itself only on a discrete NVIDIA card of 13 GiB or less. An 80-task check of 1.5 on an RTX 3060 saw no copy or code errors; agent loops may finish slightly less often, within the run-to-run spread; story +15%, code +37%.
NVIDIA. It is exact on gfx12, where 16384 is the default. `STRATA_ROUTE_PRIOR` raises KL and is an opt-in again in 0.2.8 (0.2.7 set 1.5 by itself on a discrete NVIDIA card of 13 GiB or less, and the owner took that back on 2026-10-29 because of repetition loops and weaker results in long agent sessions, #4402). `STRATA_ROUTE_PRIOR=1.5` on an RTX 3060 12 GB: story +15%, code +37%, 80 tasks with no copy or code errors, but 4.5 of 10 agent tasks against 5.6 with it off.
---
@@ -1446,7 +1465,7 @@ with `--batch 2` at 262,144 context saw 538 MiB at the free line become 395 MiB
| not stated | RTX 5070 Ti Laptop, Swift 1.5 IQ3_XXS, a new opencode session in a second folder | `env_to_user`: first request 14,073 tokens with 13,693 from the cache, 1.1 s, against 14,753 read, 11.9 s |
| 0.1.40.2 | Tesla P100 | A question after a long document re-reads up to 16K tokens (55 s at 262K) without `strata_prefix` |
| not stated | Codex CLI 0.160.0, Q2_0, RTX 5070 | First prompt 9,443 tokens in 10 s; later tool-loop turns reused about 96% from the cache and read the new part in 1-2 s. Compaction after an 85,000-token conversation reused 84,895 of 84,997 tokens with `codex_compaction_cache` and 41 of 80,683 without |
| not stated | Signalnine/q27's drift corpus, 1,462 agent turns (Qwen3.8 under Claude Code) | `tool_call_recovery`: 98.3% read as intended, 92.9% without |
| not stated | Signalnine/q27's drift corpus, 1,462 agent turns (Qwen3.8 under Claude Code) | `tool_call_recovery`: 98.3% read as intended, 92.9% without (on by default from 0.2.8) |
| not stated | RTX 3080, RVN IQ3_S config, 8-turn chat replay on a 16K-token system prompt | NaN guard on and off: byte-identical answers in 3 pairs; cost 4.8 us of GPU time a window (7.8 us at most), 0.009% of decode time |
### Context beyond 262K (contributors' runs, RTX 5080, IQ3_S, native path, PR #84)
+143
View File
@@ -0,0 +1,143 @@
# Strata on the NVIDIA DGX Spark (Linux, ARM64)
The DGX Spark is a small desktop box with a GB10 chip: a Blackwell GPU (compute capability 12.1, 48 SMs) and 20 Arm CPU cores that share
128 GB of memory. Strata 0.2.7.1 runs Qwen3.8-Flash-Next and DeepSeek-V4.1-Flash on it. This page says what works, how to install it, what
the Spark does with its memory, and how the speeds were measured. It was tested on one Spark (Ubuntu 24.04, driver 580.173, CUDA 13.0).
Nothing here is known to work on another ARM machine.
## What works, and what does not
- **Works:** Qwen3.8-Flash-Next IQ3_S and the NVFP4 GPTQ pack (64K context, every expert in the GPU's cache), DeepSeek-V4.1-Flash 2.00 bpw
(a short chat), the Terminal (`strata launch`, `strata update`, `strata serve`) and the engine packages of the signed feed.
- **Not built for ARM:** the desktop app (no AppImage; use the Terminal), the HIP, SYCL and CPU-only engines, and the Q2_0 and Gyro packs,
whose CPU kernels are x86-only (the engine refuses them at start). Picture reading is compiled in and was not tried on the Spark.
- **Not tested:** contexts above 128K, more than eight chat slots (four and eight were measured: "Several chats at once" below), a long soak. `strata doctor --live` lists the GB10 with no memory
figure, because `nvidia-smi` reports none for it.
## Install with the Terminal
```sh
curl -fsSL https://github.com/Niko1221/Strata/releases/latest/download/install.sh -o install.sh && sh install.sh
strata launch
```
`install.sh` unpacks the Terminal into `~/.local/share/strata` (no administrator rights) and checks the release's signed feed. `strata launch`
fetches the `linux-aarch64` engine package, shows what each model needs, downloads the one you choose and starts it. Pick Qwen3.8-Flash-Next
IQ3_S (84 GB download) or the NVFP4 pack, or DeepSeek-V4.1-Flash 2.00 bpw (257 GB). The engine package needs glibc 2.39 or newer
(Ubuntu 24.04, DGX OS 7) and NVIDIA driver 580.65 or newer.
## Install from a source checkout
```sh
./setup.sh --setup --family qwen --model IQ3_S --context 65536 --vision no --yes --no-start --data-dir ~/Strata-data
./setup.sh --setup --family nvfp4 --model NVFP4 --context 65536 --vision no --yes --no-start --data-dir ~/Strata-data
```
`--data-dir` must be a folder you can write (the default, next to the checkout, may be under `/home`, which a normal user cannot create).
Setup finds the GB10, calls it unified memory, takes the engine from the feed (or compiles it with `spark/build_engine.sh` when the release
has none) and writes the run config with the settings measured on this part:
| Setting | Why |
|---|---|
| `--mmap-experts` | the experts are read from the model files in place, so the GPU's cache holds all 24,576 of them instead of a second copy in RAM |
| `--prefill auto:32768` | a 32,768-token prompt chunk; the automatic 8,192 measured 2 to 6% slower here |
| `--mtp-q4 all` | the draft layer as 4-bit copies: +3.5% decode, the answers do not change because the target checks every draft |
| `--mtp-hnorm stream` | one norm per hyper-connection stream in the draft layer: draft acceptance 0.60 to 0.63, +4.3% decode |
Setup prints `--draft-vocab en` as a tip and does not set it: it measured +3.1% decode on English prompts, and it would cut the Chinese,
Japanese and Korean drafts.
Start a model with its run script (`./run-iq3_s.sh`) or `./setup.sh`.
## Build the engine yourself
You need the CUDA 13.0 toolkit (`/usr/local/cuda`; DGX OS has it), cmake, ninja and Python 3.10 or newer.
```sh
python3 -m venv .venv-build
.venv-build/bin/pip install -r requirements.txt -r requirements-build.txt
PATH="$PWD/.venv-build/bin:/usr/local/cuda/bin:$PATH" PYTHON="$PWD/.venv-build/bin/python" JOBS=16 sh spark/build_engine.sh
cp -r out-spark/engine ./engine
```
About 3 minutes for the Qwen engine and 6 with the DeepSeek engine and the picture reader (the first run also fetches llama.cpp at the
pinned commit). `out-spark/engine/` holds `strata`, `strata-dsv41`, `strata-vision`, `libstrata_kq.so`, the CUDA libraries and a `BUILD.json`.
`BUILD_VISION=0` and `BUILD_DSV41=0` leave a part out. `packaging/build_cuda_linux_aarch64.sh` runs this build and makes the engine packages
for the feed ([packaging/README.md](../packaging/README.md)). A model on a Q2_0 pack cannot run: its CPU kernels need AVX-512.
## What the Spark does with its memory
The GPU's memory and the CPU's are the same 121.6 GB, so a model's experts are held once. With `--mmap-experts` there is one copy: the model
file's pages, which the GPU cache uses.
| | IQ3_S | NVFP4 GPTQ |
|---|---|---|
| Expert bytes | 50.3 GB | 68.0 GB |
| Expert cache | 24,576 of 24,576 slots, 46.84 GiB | 24,576 of 24,576 slots, 63.29 GiB |
| n-gram table | 28.8 GB, locked in RAM (`--ple-io ram`) | 51.2 GB, read from the SSD |
| Context | 64K, 8-bit KV, 32,768 cells streamed | same |
| Free memory at start | about 1 GiB | about 1.2 GiB |
The NVFP4 table does not fit in RAM beside the page cache the 68 GB expert file needs. Locking it measured 137 tok/s on a 4K prompt and 26 tok/s
decode, against 1,252 and 39 without. Leave it on the SSD (setup does).
DeepSeek-V4.1-Flash 2.00 bpw places 12,163 of its 15,744 experts in the GPU's share and runs the rest on the CPU lane from the model's files.
The engine sizes that from the memory Linux can still give back (`MemAvailable` less 6 GiB, `DS4_UMA_HEADROOM_GIB`), because the model files' page
cache is most of the memory `cudaMemGetInfo` calls used.
## Speeds
Measured on the release-candidate engine package of 0.2.7.1 with `tools/dgx_bench.py`: fresh 4K, 16K and 32K prompts at temperature 0, 256-token
answers, 64K context, the model's own run config, four samples a cell in two blocks (on, off, off, on), medians. "Table off" is
`STRATA_SM121_PERF=0`. One Spark, 27 October 2026; the figures move a few percent between sessions.
| Model | Table | Prompt tok/s (4K / 16K / 32K) | Decode tok/s (4K / 16K / 32K) |
|---|---|---|---|
| Qwen3.8-Flash-Next IQ3_S | on (default) | 1,296 / 1,680 / 1,735 | 45.5 / 46.5 / 46.5 |
| Qwen3.8-Flash-Next IQ3_S | off | 1,203 / 1,416 / 1,276 | 48.5 / 45.3 / 45.2 |
| Qwen3.8-Flash-Next NVFP4 | on (default) | 1,408 / 1,670 / 1,698 | 42.8 / 40.5 / 40.1 |
| Qwen3.8-Flash-Next NVFP4 | off | 1,156 / 1,350 / 1,353 | 41.0 / 41.2 / 38.4 |
The table makes prompts faster: IQ3_S 1.20x over the three sizes (90% interval 1.12 to 1.31; 1.08x at 4K, 1.19x at 16K, 1.36x at 32K), NVFP4
1.24x (1.14 to 1.36). It does not change decode speed in this check: 0.996x (0.97 to 1.02) on IQ3_S, with the 4K cell 0.94x, and 1.02x (1.00 to
1.05) on NVFP4. The first run of the port (26 October) had measured +5% each for the two decode switches; this check did not see it.
DeepSeek-V4.1-Flash 2.00 bpw, 8K context, the default Faster mode (5 of 6 experts per word), 11,187 of 15,744 experts in the GPU's share: a
short chat answers at 13 to 18 tok/s (the first answer after a start is the slowest). Full quality (`--experts 6`) places more of them and was
measured at 17 to 23 tok/s on a 1.7K-token chat in the first run.
Several chats at once (`"parallel"` is 4 on a Spark for `strata launch`; the setup script leaves it off): IQ3_S, 2K-token prompts, 128-token cap,
the aggregate tokens per second over all clients: 8 clients 35.6, 16 clients 41.7, 32 clients 47.3 at 4 slots; 32 clients 54.2 at 8 slots. With
a prompt of its own and a hidden code for each client, every answer named its own code and nobody else's (8 clients at 4 slots, 16 clients at 8
slots), and none failed or hung.
## The four speed defaults of this part
On a GB10 the engine switches on four settings that were measured here, and prints them at every start. `STRATA_SM121_PERF=0` (or
`STRATA_DEFAULTS=026`, which gives back everything 0.2.7.1 changed) turns the whole set off; a switch you set yourself is kept.
| Switch | What it does | Measured in the first run (26 October) |
|---|---|---|
| `STRATA_PF_FUSED=1` | the fused IQ prompt kernels | IQ3_S prompt +10 to 27% |
| `STRATA_PREFILL_NVFP4=w4a4` | NVFP4 prompt matrix product with FP4 activations | NVFP4 prompt +16% |
| `STRATA_HC_UPMIX=1` | the hyper-connection read's up projection and mix in one kernel | decode +5.4% |
| `STRATA_SPEC_COST=1` | the draft window follows what a draft costs | decode +4.7% |
**What the first two switches do to the answers.** The fused prompt path and the FP4 activations round differently from the paths they replace,
and the other two do not touch the text (the fused hyper-connection read gives bit-identical logits; the cost-aware window only chooses
which drafts to try, and the target checks every draft). 24 short greedy prompts gave 24 identical answers on IQ3_S and on NVFP4. On
24 long prompts (1.5K to 24K tokens, the first-token logits compared with the switches off, `STRATA_DUMP_FIRST_LOGITS`) the distribution
does move: IQ3_S mean KL 0.019 (largest 0.082), NVFP4 0.032 (largest 0.126); the most likely token changed on 2 of 24 prompts for each, in
IQ3_S's case a near tie both times (0.20 against 0.20, 0.15 against 0.14); and a 24-token greedy answer stayed the same on 1 of 6 prompts. Two runs
of the same setting gave identical logits, so the difference is the switches, not noise. All of the IQ3_S difference comes from the fused
prompt path, and all of the NVFP4 difference from `w4a4`. On the 61-task quality suite (long-document retrieval, extraction of up to 72 exact rows, summaries, code, JSON, agent loops with tools), run through the server with the switches on and off: IQ3_S passed 56 tasks on and 51 off, NVFP4 56 on and 52 off. Retrieval found all 63 facts in every run, and no agent loop made an invalid tool call. The extraction facts were equal on IQ3_S (621 of 642 both ways); on NVFP4 one 40-row register lost a row with the switches on (616 against 621 of 642). One summary in each model gained or lost a flagged name or figure. Answers differ in wording on 21 of 61 tasks (IQ3_S) and 25 of 61 (NVFP4), and the wins and losses go both ways, so read the suite as "no loss we could measure", not as an improvement. The same setting run twice gave identical answers, so every difference comes from the switches. If you want the answers of 0.2.7, set `STRATA_SM121_PERF=0`: you keep the other
Spark settings and lose the prompt speed-up.
With `"parallel": 4` (the default) the set adds up: 32 clients at 2,000-token prompts read 41.3 tok/s with all four on and 35.7 with all off. At
`"parallel": 8` the two decode switches cost about 3.5% each; set `STRATA_HC_UPMIX=0 STRATA_SPEC_COST=0` there.
## What this does not cover
Decode on the 48-SM GB10 is limited by the GPU's work, about 930 kernel launches per token. No configuration switch measured here moved it more
than a few percent. A DFlash2 drafter is slower here than the MTP drafter (37 against 45 tok/s) and is not used. Teacher-forced quality over
long text and task suites was run only for the prompt path (above).
+4 -3
View File
@@ -24,6 +24,7 @@ install does not need a source checkout or a startup `.bat` file.
| Linux x86-64 with Intel Arc | [Intel Arc guide](INTEL_ARC.md); install the driver/compute runtime and check model preparation requirements |
| Apple Silicon Mac, macOS 15 or newer | [Mac guide](MAC.md). Intel Macs are not supported by the Mac package. |
| AMD Ryzen AI Max / Strix Halo | [Strix Halo guide](STRIX_HALO.md) |
| NVIDIA DGX Spark (Linux ARM64) | [DGX Spark guide](DGX_SPARK_LINUX.md). Use the Terminal; there is no desktop app for ARM64 Linux. |
Memory and disk needs depend on the **model, quantization, context and enabled features**. Check
[which model fits](MODELS.md#pick-by-memory); download size is not the same as RAM use. Unified memory counts once.
@@ -37,10 +38,10 @@ hardware drivers. Models and updates need an internet connection to download.
1. Open the [latest release](https://github.com/Niko1221/Strata/releases/latest).
2. Download the one installer for your system. There is one per system, not one per graphics card:
- **Windows:** run `Strata-0.2.7-windows-x64-setup.exe`. It installs for your user and needs no administrator rights.
- **Linux:** make `Strata-0.2.7-linux-x86_64.AppImage` executable, then open it. If FUSE is unavailable, run the
- **Windows:** run `Strata-0.2.8-windows-x64-setup.exe`. It installs for your user and needs no administrator rights.
- **Linux:** make `Strata-0.2.8-linux-x86_64.AppImage` executable, then open it. If FUSE is unavailable, run the
downloaded file with `--appimage-extract-and-run`. [Linux help](TROUBLESHOOTING.md).
- **Mac:** open `Strata-0.2.7-macos-arm64.dmg`, drag Strata into Applications, then open it. [Mac help](MAC.md).
- **Mac:** open `Strata-0.2.8-macos-arm64.dmg`, drag Strata into Applications, then open it. [Mac help](MAC.md).
3. On Windows and Linux the installer has no engine inside. On first start the app shows the engine for your
graphics card with its size and one **Download** button, with progress and resume. If you already downloaded the
engine file, choose **I already have the file** and it installs without internet. The Mac file keeps its small
+13
View File
@@ -38,6 +38,11 @@ engine package. Otherwise [build the image yourself](DOCKER.md#build-it-yourself
- **Linux**, for example Ubuntu 24.04, with Intel's GPU driver: the `xe` or `i915` kernel driver plus the compute runtime and
Level Zero. On Ubuntu: `intel-opencl-icd libze1 libze-intel-gpu1`, or follow Intel's
[client GPU guide](https://dgpu-docs.intel.com/driver/client/overview.html).
- **The CPU and the model folder.** The CPU does not need AVX-512. A Ryzen 3000-5000 or an Intel Core 12th-14th generation runs the
experts that do not fit on the card with AVX2 (measured on an A750 with a Ryzen 5 5600X, [INTEL.md](INTEL.md)). This applies to
model folders that contain `native_experts.txt` (IQ3_XXS, IQ3_S, NVFP4, Unsloth). An older Q2_0 folder has no such file, and its
CPU kernel needs AVX-512. With that folder the engine stops with "this CPU cannot run the expert kernel", and `strata doctor`
warns about it before the start. Building the model folder again with `strata setup` (or `tools/iq_pack.py`) fixes it.
- For a source build without Docker: **Intel oneAPI** with the DPC++ compiler (`icpx`, 2025.3 or newer) and **oneMKL**
(about 5 GB), `cmake` 3.24+, `ninja`, `git`, Python 3. The build fetches ggml unless you point `STRATA_GGML_DIR` at a
llama.cpp checkout.
@@ -121,6 +126,13 @@ Things that matter on an Arc (details in [INTEL.md](INTEL.md)):
- `--layer-split auto` on two equal cards takes the split that leaves the fullest card the most VRAM to spare, within 0.2% of
the best predicted time (`STRATA_SPLIT_HEADROOM_TOL`). `STRATA_SPLIT_HEADROOM=0` turns that off. A full card under `xe` moves
buffers to system memory instead of failing. The startup line names both splits when the choice moves.
- **Two Arc B-series cards decode faster by default.** With exactly two B-series cards (B50, B60, B65, B70, B570, B580) in a layer
split, the first card starts the next window while the second still checks this one (`--pipeline-windows 2`), and setup writes
`--spec 2` for the pair. On 2x Arc Pro B65 this decodes 17% to 23% faster than the old serial `--spec 4` setup, 5 pairs out of 5:
chat 71-74 tok/s against 60-63, JSON 80-83 against 67-68, code 72-78 against 59-65, plain text 67-68 against 56-57. The answers are
those of a serial `--spec 2` run (100 of 100 agent requests); a serial `--spec 4` run differs in a rare near-tie (3 of 100).
One card, an A-series card, a mixed pair and three or more cards run as before. `--pipeline-windows 0` turns it off, and a
`--spec` of your own, put in the config's `user_args`, is kept.
- **Arc Pro B60:** PCI id `8086:e211` (`lspci -nn`), `sycl-ls` prints `Intel(R) Arc(TM) Pro B60 Graphics 20.1.0`, so
`-DSTRATA_SYCL_AOT=bmg-g21` is the right target. `setup_intel.py` knows both B60 ids, `e211` and `e221`. On a `--layer-split`
the startup line "N experts are neither in VRAM nor mirrored" counts the other card's layers; read the following
@@ -149,6 +161,7 @@ Things that matter on an Arc (details in [INTEL.md](INTEL.md)):
| Community | One Arc Pro B60 24 GB | Coder IQ1_M with 4,042 of 12,288 experts mirrored in RAM: 11.9 tok/s decode | same |
| Maintainers | Arc Pro B70 32 GB (xe), 0.1.40.2 and later | Flash-Next IQ3_S with 11.5k of 24.6k experts resident: 10-prompt gate passes, prompt 980-1,000 tok/s on 4K tokens, decode 30 (prose) to 41 (code) tok/s | [INTEL.md](INTEL.md#arc-pro-b70-with-a-model-that-does-not-fit-flash-next-iq3_s) |
| Maintainers | Arc A750 8 GB (i915) | Flash-Next IQ3_XXS about 10-19 tok/s decode | [INTEL.md](INTEL.md#arc-a750-and-the-other-alchemist-cards) |
| Maintainers | 2x Arc Pro B65 32 GB (xe), 0.2.8 | Flash-Next IQ3_S, every expert in VRAM, the JIT package build: decode 71-83 tok/s (chat, JSON, code), prompts 1,261 / 1,511 / 1,628 tok/s at 4K / 8K / 16K | the bullet above |
Two cards on `xe` (2x Arc Pro B70, IQ3_S, 0.1.44): `--layer-split auto` picked a split at about 11 tok/s where
`--layer-split 30` ran about 54. The auto choice now prefers the split with the most VRAM headroom (above).
+2 -2
View File
@@ -3,8 +3,8 @@
This page is for people with an Apple Silicon Mac (M1 or newer). Strata runs there on its own Metal engine: the NVIDIA kernels
are replaced by Metal ones (`metal/`), and the host code and the server are the PC's. Intel Macs cannot run it.
DeepSeek V4.1-Flash does not run on a Mac yet. It runs in the Terminal on NVIDIA RTX 30 and 50 cards and on the Radeon AI PRO
R9700 ([MODELS.md](MODELS.md#deepseek-v41-flash)).
DeepSeek V4.1-Flash runs on a Mac with 64 GB of memory or more, as text only: [DEEPSEEK_MAC.md](DEEPSEEK_MAC.md). It has its
own engine, separate from the Qwen one described on this page.
> **On this page:** [What you need](#what-you-need) · [Install](#install) · [Defaults](#defaults-and-options) ·
> [Gyro](#gyro-s-and-gyro-m-agentionais-trellis-experts) · [Measured](#measured-m5-max-128-gb) · [Limits](#limits-this-version) ·
+3 -2
View File
@@ -71,11 +71,12 @@ you have the disk. Perplexity is measured on a fixed set of 4,995 test texts; lo
| NVIDIA RTX 30, 40 and 50 series, 12 GB or more | Windows, Linux |
| AMD Radeon AI PRO R9700 | Linux |
| AMD Ryzen AI Max (Strix Halo, gfx1151, 128 GB unified; 64 GB or more shared with Windows) | Windows |
| Apple Silicon Mac, 64 GB or more, macOS 15 or newer (text only, [DEEPSEEK_MAC.md](DEEPSEEK_MAC.md)) | macOS |
Tested on an RTX 3060 (Linux), an RTX 5070 (Windows), four R9700 cards (Linux) and a Ryzen AI Max+ 395 with 128 GB unified (Windows:
14 to 16 tokens a second on code with the default Faster mode, 10 to 11 on chat, 78 tokens a second on a 4K prompt and 109 on
8K). The RTX 30 kernels were also checked on an RTX A4000. Windows on RTX 30 and the RX 9070 are built but were not run. Mac
and Strix Halo on Linux do not run it yet; on Strix Halo Windows the graphics memory is the RAM, so most of the experts are
8K). The RTX 30 kernels were also checked on an RTX A4000. Windows on RTX 30 and the RX 9070 are built but were not run. On a
Mac it was run on an M5 Max with 128 GB ([DEEPSEEK_MAC.md](DEEPSEEK_MAC.md)). Strix Halo on Linux does not run it yet; on Strix Halo Windows the graphics memory is the RAM, so most of the experts are
read from the SSD and an NVMe drive matters.
RTX 40 cards are covered by the engine packages since 0.2.7 (sm_89 code); we have not run one ourselves, see [DEEPSEEK41.md](DEEPSEEK41.md#the-engine-package) for a user's measurement.
+3
View File
@@ -181,6 +181,9 @@ tokens. The tokens are the serial loop's, up to CPU rounding.
- **Default:** on for AMD RDNA4 (gfx1200 / gfx1201) with `--serve`, the draft layer, and a split of two or more stages when the
drafter can sit on the first card, otherwise three or more. The log says "is on by default for a layer split of N stages".
`--pipeline-windows 0` or `STRATA_PIPELINE_AUTO=0` turns it off. NVIDIA builds keep it opt-in (`--pipeline-windows 2`).
The Intel Arc engine turns it on for exactly two Arc B-series (Battlemage) cards, with `--serve` and the draft layer (`--spec 2`
or more): on 2x Arc Pro B65 decode is 17% to 23% faster, and setup writes `--spec 2` for those cards ([INTEL_ARC.md](INTEL_ARC.md)).
It runs exactly two cards, so one card, Arc A-series cards and three or more cards stay serial.
- **Draft layer placement:** with the pipeline on, the draft layer loads on the first card, whose window has finished when the
chain starts. `STRATA_MTP_STAGE0=0` keeps the old place.
- **Off, with one log line saying why:** `--batch` slots, `--peer-device`, the helper caches (`--expert-cache-device1..3`, which
+5 -3
View File
@@ -87,9 +87,11 @@ The choice is kept in the model's config (`"cuda": 12`). Starts and `UPDATE.bat`
Setting the model up again chooses again (add `--cuda 12` to keep a forced choice). A Pascal or Volta card added to a model at a
start (`--gpus`) moves that model to the CUDA 12 engine.
`--cuda 12` is also the way to run Strata with an NVIDIA driver older than 580. The CUDA 12 engine needs driver 528 or newer on
Windows and 525 on Linux (`CUDA12_MIN_DRIVER` in `setup.py`); setup's "driver too old" stop says so. The Docker `cuda12` image
asks for 575 ([DOCKER.md](DOCKER.md)). Drivers that old were not tested here.
`--cuda 12` is also the way to run Strata with an NVIDIA driver older than 580. The oldest driver the CUDA 12 engine runs on is the
`min_driver` of its package (575.51 or newer at the moment, as in the Docker `cuda12` image, [DOCKER.md](DOCKER.md)). Setup reads it from
the installed package or the signed update feed and says it in the "driver too old" stop; `strata update` enforces the same number. NVIDIA's
own table lets CUDA 12 programs start on older drivers (525 on Linux, 527 on Windows), but the packages are built with CUDA 12.9 and were
not tested on such drivers, so Strata does not promise that range.
### Mixed cards
+2 -2
View File
@@ -23,8 +23,8 @@ Use the [current release](https://github.com/Niko1221/Strata/releases/latest). Y
| System | What to install | Engine variant |
| --- | --- | --- |
| Linux | `Strata-0.2.7-linux-x86_64.AppImage` (it downloads the engine on first start), or `strata launch` | `linux-x86_64-hip-halo` |
| Windows | `Strata-0.2.7-windows-x64-setup.exe` (it downloads the engine on first start), or `strata launch` | `windows-x86_64-hip-rdna3` |
| Linux | `Strata-0.2.8-linux-x86_64.AppImage` (it downloads the engine on first start), or `strata launch` | `linux-x86_64-hip-halo` |
| Windows | `Strata-0.2.8-windows-x64-setup.exe` (it downloads the engine on first start), or `strata launch` | `windows-x86_64-hip-rdna3` |
Packaged engines carry the matching hipBLASLt tuning table for gfx1151 and select it on their own. Setup recognizes `gfx1151` and
PCI ID `1002:1586`; other integrated Radeon chips have separate support limits. Unified memory is counted once. Setup does not
+1 -1
View File
@@ -18,7 +18,7 @@ strata launch --status
**I cannot find `START-HERE.bat` or `SETUP.bat`.**
Open the installed Strata app, or run `strata launch`. Those wrappers are in the source repository and are not the
startup path for the app. For a fresh desktop install, download the one installer for your system
(`Strata-0.2.7-windows-x64-setup.exe`, `Strata-0.2.7-linux-x86_64.AppImage` or `Strata-0.2.7-macos-arm64.dmg`), not a
(`Strata-0.2.8-windows-x64-setup.exe`, `Strata-0.2.8-linux-x86_64.AppImage` or `Strata-0.2.8-macos-arm64.dmg`), not a
`strata-engine-…` archive.
**`strata` is not found.**
+47
View File
@@ -0,0 +1,47 @@
// include/strata/core/auto_cache.hpp - the slot count of the automatic expert cache (--expert-cache auto), as a rule with a test.
//
// The auto cache is sized from the VRAM free after the weights, the session and the drafter, minus --vram-reserve-mib (the reserve is what
// the operator wants LEFT once everything is loaded), the draft head that binds later (priced), the prompt path's own buffers when it does
// not borrow cache slots (priced) and the second verifier of --pipeline-windows (priced). What was never priced is what the load allocates
// AFTER the cache whatever the settings: the CUDA libraries the prompt path and the verifier warm up (cuBLAS handles and workspaces, the
// lazily loaded modules) and the verifier's small buffers. Measured with STRATA_TRACE=1 on the same build, config and flags
// (--vram-reserve-mib 700, 256K context, MTP, 4 drafts):
// RTX A4000 16 GB (z440, IQ3_XXS): 883 MiB free after the cache -> 719 after the head and the prompt path (-164) -> 461 after the
// verifier and the drafter's binding (-258 = 178 MiB draft head, priced, + 80) = 244 MiB unpriced
// RTX A6000 48 GB (boston, IQ3_S): 919 -> 755 (-164) -> 459 (-296 = 213 MiB draft head, priced, + 83) = 247 MiB unpriced
// --prefill 512 / 2048 / auto on the A4000: the same 164 + 80. v0.2.6 ends at 461 MiB on the same card: not new, but every 16 GB card with
// the default 700 MiB ends under the 512 MiB line the engine itself warns about (#2065, #4474), and the warning cannot be cured by the
// operator raising the reserve by the advice's figure without losing the cache it was meant to keep.
// So the reserve is now honest: the cache leaves `reserve + kLateAllocMib`, and the card ends with about the reserve.
#pragma once
#include <cstdint>
namespace strata::core {
/// MiB the load allocates after the cache that no setting prices (see above; measured 244-247, a little margin).
constexpr int64_t kLateAllocMib = 256;
struct AutoCacheSlots {
int64_t slots = 0; ///< the cache's slot count (of the largest blob), >= 0
bool late_priced = false; ///< kLateAllocMib was left out of the cache
};
/// `free_b`: VRAM free now; `reserve_b`: the reserve and everything else that is priced (bytes); `blob`: the largest slot; `min_slots`: the least a working
/// cache needs (#496). `late_mib` 0 turns the late allocation off (STRATA_DEFAULTS=026). The late allocation is only left out when the cache still has
/// `min_slots` after it: a small card keeps the sizing it had (its reserve is lowered further elsewhere, #496), never a smaller cache than it can work with.
inline AutoCacheSlots auto_cache_slots(int64_t free_b, int64_t reserve_b, int64_t blob, int64_t min_slots, int64_t late_mib = kLateAllocMib) {
AutoCacheSlots r;
if (blob <= 0) return r;
const int64_t plain = (free_b - reserve_b) / blob;
const int64_t late = late_mib > 0 ? (free_b - reserve_b - (late_mib << 20)) / blob : plain;
if (late_mib > 0 && late >= min_slots && late > 0) {
r.slots = late;
r.late_priced = true;
} else {
r.slots = plain > 0 ? plain : 0;
}
return r;
}
} // namespace strata::core
@@ -465,6 +465,33 @@ public:
superseded_ += dropped;
return dropped;
}
// A batch slot's chain holds one checkpoint (the one its admission copied in), while the main session's parked copy of the same
// conversation keeps the whole chain - the root checkpoint at the end of an agent's system prompt and tools among them, the one its
// next conversations borrow (borrows()). Parking the slot (#2526) would drop that copy as superseded (#342: the slot's chain holds its
// deepest checkpoint) and the root with it: every sibling then reads the shared prefix again (#4420, 0.2.6 -> 0.2.7: 323 ms -> 4130 ms
// for a 5 K-token prefix). True when drop_superseded(ids, images, checkpoints, cvec) would drop an entry that has a checkpoint the
// given chain does not hold. Nothing is changed.
bool supersedes_richer(const std::vector<int32_t>& ids, const std::vector<ConversationImageKey>& images,
const std::vector<ConversationCheckpoint>& checkpoints, bool cvec) const {
auto held = [&](const ConversationCheckpoint& c) {
if (c.ids == ids && c.imgs == images) return true;
for (const auto& k : checkpoints)
if (k.ids == c.ids && k.imgs == c.imgs) return true;
return false;
};
for (size_t i = 0; i < entries_.size(); ++i) {
const auto& e = entries_[i];
const ConversationCheckpoint* deepest = nullptr;
for (const auto& c : e.checkpoints)
if (!deepest || c.ids.size() > deepest->ids.size()) deepest = &c;
if (e.cvec != cvec || !deepest || deepest->ids.empty() || !held(*deepest) ||
(guarded_ != 0 && serials_[i] == guarded_))
continue;
for (const auto& c : e.checkpoints)
if (!held(c)) return true;
}
return false;
}
size_t superseded() const { return superseded_; }
/// --conversation-cache-park-gain N: the longest parked entry this conversation's outgoing state supersedes, in
/// tokens, 0 when it has none. It is the entry drop_superseded() would remove, so the caller can tell whether a
+3 -1
View File
@@ -1,10 +1,12 @@
// include/strata/core/defaults_026.hpp - STRATA_DEFAULTS=026: the master switch of the Qwen engine's 0.2.7 defaults.
//
// Every default the Qwen engine (`strata`) changed since v0.2.6 goes back to what v0.2.6 did when STRATA_DEFAULTS=026 is in the
// environment (the app's "off" for its speed switches sends it): the residency-biased routing (STRATA_ROUTE_PRIOR), the wide
// environment (the app's "off" for its speed switches sends it): the wide
// hyper-connection up read (STRATA_GR_UP_WIDE), the stream-split hyper-connection read (STRATA_GR_V3), the int8 prompt attention
// (STRATA_PROMPT_ATTN_IMMA), the four gfx12 decode switches of the arch table, the PLE residency policy (STRATA_PLE_AUTO_RAM), and the
// gfx12 decode miss split of 0.15 (back to 0.55). KVA is off by default in 0.2.7 and stays whatever STRATA_KVA says.
// The residency-biased routing (STRATA_ROUTE_PRIOR) was in this set in 0.2.7; it is off by default again (owner, 2026-10-29), so the master switch
// has nothing to take back there, and route_prior_default() answers 0 with the switch on or off.
// An explicit per-feature variable (STRATA_ROUTE_PRIOR=2, STRATA_GR_V3=1, STRATA_PLE_AUTO_RAM=1, ...) or flag (--pcie-frac, --ple-io)
// still wins over the master switch. Each rule is a pure function that takes the answer of qwen_defaults_026() as its `v026`
// argument, so a test can check both settings without touching the environment (src/core/arch_defaults_test.cpp,
+11
View File
@@ -36,6 +36,7 @@
#include <cstddef>
#include <condition_variable>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
#include <thread>
@@ -94,6 +95,16 @@ bool host_available_memory(HostMemory& m, const std::string& meminfo = "/proc/me
const std::string& self_cgroup = "/proc/self/cgroup",
const std::string& cgroup_root = "/sys/fs/cgroup");
/// #4051: wait for the RAM an arena needs to come back. An engine that was still exiting (or a program that was ending)
/// holds memory a few seconds longer than its process; loading into that shortfall sent a 60 GiB host into reclaim for
/// good. Calls `sample(available)` (false: the RAM cannot be read; the wait ends) every `step_s` seconds, through
/// `sleep(step_s)`, until `available >= need` or `max_wait_s` seconds have gone. `waited` gets the seconds waited,
/// `available` the last reading. True when the RAM is there. `progress(waited, available)` is called about every
/// `note_s` seconds while waiting. `max_wait_s <= 0` is no wait at all.
bool wait_for_ram(uint64_t need, double max_wait_s, double step_s, double note_s,
const std::function<bool(uint64_t&)>& sample, const std::function<void(double)>& sleep,
const std::function<void(double, uint64_t)>& progress, double& waited, uint64_t& available);
/// Bound a resident budget by RAM and commit capacity after headroom; leave 256 MiB more when clamping.
/// Pass UINT64_MAX for commit when the platform does not report it.
uint64_t clamp_resident_budget(uint64_t requested, uint64_t physical, uint64_t commit, uint64_t headroom);
+5
View File
@@ -8,6 +8,9 @@
//
// STRATA_SM86_PERF=0 turns the sm_86 table off (cards of compute capability 8.6: RTX 3050-3090, RTX A2000-A6000)
// STRATA_SM60_PERF=0 turns the sm_60 table off (Pascal GP100: Tesla P100)
// STRATA_SM121_PERF=0 turns the sm_121 table off (GB10: NVIDIA DGX Spark; new in 0.2.7.1, also off under STRATA_DEFAULTS=026). Its
// switches are measured speed settings, not bit-exact ones: the fused prompt kernels and the FP4-activation NVFP4
// prompt round differently from the paths they replace (24 of 24 greedy answers identical; no KL run yet).
//
// A switch the user set (to anything, including 0) is never overridden. Applied once at start, before any engine code reads its
// switches; the table is used only when every visible CUDA device has the same compute capability.
@@ -22,6 +25,8 @@ namespace strata::core {
/// The environment settings that are the defaults of a card with compute capability major.minor (raw, as the driver reports it);
/// empty for every other card and when its off switch is 0.
std::vector<std::pair<std::string, std::string>> nv_default_env(int major, int minor);
/// The same with the answer of STRATA_DEFAULTS=026 given (the sm_121 table is empty then), so a test can check both without the environment.
std::vector<std::pair<std::string, std::string>> nv_default_env(int major, int minor, bool defaults026);
/// Sets `nv_default_env(major, minor)` in this process's environment where the user has not set the variable, prints one line to
/// stderr when it set anything, and returns the names it set.
+11
View File
@@ -34,4 +34,15 @@ inline bool ple_auto_ram_allowed(const char* env_value, bool v026) {
return !v026;
}
/// #4518: the table was locked at start on the policy's word (table + arena + headroom fit), but what the rest of the engine takes after
/// that (the KV pools, the pinned staging, the GPUs' host buffers) is not known then. On a 128 GB two-GPU Windows PC it left less than the
/// 5 GiB the automatic conversation cache needs, and the cache turned itself off. The cache is worth more than the lock (parking a 100 K
/// conversation is 1.1 s against 30 s to read it again, the lock was measured at 2-3% of prompt speed), so once the cache is to be sized:
/// does giving the lock back make the automatic cache bigger? `available` is the RAM the cache sees now (the table locked), the table's
/// pages count as available again once unlocked (clean file pages). Unknown telemetry: no.
inline bool ple_lock_blocks_cache(std::optional<uint64_t> available, uint64_t table_bytes, int64_t min_free_mib) {
if (!available || table_bytes == 0 || *available > UINT64_MAX - table_bytes) return false;
return auto_conversation_cache_mib(*available + table_bytes, min_free_mib) > auto_conversation_cache_mib(available, min_free_mib);
}
} // namespace strata::core
+14 -8
View File
@@ -49,16 +49,22 @@ inline bool sm120_default(int major, int minor) { return is_sm120(major, minor)
// `total_bytes`: the card's memory (cudaDeviceProp::totalGlobalMem); `integrated`: a GPU that shares the PC's memory.
constexpr double kSmallCardGiB = 13.0; // "a 12 GB card": 12 GB cards report 11.6 - 12.0 GiB, 16 GB ones 15.6 - 16.0
/// The residency-bias bonus a discrete CUDA card of 13 GiB or less gets when the user set nothing: THE one place the value lives. 1.5 since the
/// owner decision of 2026-10-26 (it was 2.0 in the first 0.2.7 candidates): on the RTX 3060 12 GB with IQ3_XXS the copy and code check found real
/// defects at 2.0 and none in 19 tasks at 1.5 or 1.0, and 1.5 keeps the speed (story +15%, code +37% against off). A longer 61-task check of
/// 0 / 1.5 / 2.0 is still running; if it shows defects at 1.5 this falls back to 1.0.
/// The residency-bias bonus the user can ask for on a discrete CUDA card of 13 GiB or less (STRATA_ROUTE_PRIOR=1.5; the app's Expert-settings row
/// "Faster answers on 12 GB NVIDIA cards" writes it): THE one place the value lives. 1.5 since the owner decision of 2026-10-26 (2.0 had real defects in
/// the copy and code check on the RTX 3060 12 GB with IQ3_XXS; none in 19 tasks at 1.5 or 1.0). Speed against off: story +15%, code +37%.
/// It was the default on those cards in 0.2.7 only. Owner decision of 2026-10-29: OFF by default again (#4402: repetition loops and weaker results in long
/// agent sessions; our own 0.2.7 agent check passed 4.5 of 10 tasks with it on against 5.6 with it off). It stays an opt-in.
constexpr float kRoutePriorSmallCard = 1.5f;
/// STRATA_ROUTE_PRIOR when the user set nothing: kRoutePriorSmallCard (residency-biased routing, changes which experts run; owner-approved for 12 GB
/// cards) on a discrete CUDA card of 13 GiB or less, 0 everywhere else. Measured on an RTX 3060 12 GB only.
inline float route_prior_default(bool hip, bool integrated, double total_bytes, bool v026 = false) {
return !v026 && !hip && !integrated && total_bytes > 0.0 && total_bytes / 1073741824.0 <= kSmallCardGiB ? kRoutePriorSmallCard : 0.0f;
/// STRATA_ROUTE_PRIOR when the user set nothing: 0 on every card (residency-biased routing changes which experts run, so it is only on when asked for:
/// STRATA_ROUTE_PRIOR=1.5, or the Expert-settings row). The same answer as v0.2.6 and as 0.2.7 with STRATA_ROUTE_PRIOR=0 or STRATA_DEFAULTS=026.
/// The arguments are kept so a caller and the table test (arch_defaults_test) still ask the same question per card; none of them changes the answer.
inline float route_prior_default(bool /*hip*/, bool /*integrated*/, double /*total_bytes*/, bool /*v026*/ = false) { return 0.0f; }
/// The lambda the engine uses given the text of STRATA_ROUTE_PRIOR (nullptr = unset): any set value is the user's own and wins on every card
/// (atof; "0" and text that reads as 0 are off; "1.5" is what the Expert-settings row writes), unset is route_prior_default (0).
inline float route_prior_resolve(const char* env, bool hip, bool integrated, double total_bytes, bool v026 = false) {
return env != nullptr ? (float) std::atof(env) : route_prior_default(hip, integrated, total_bytes, v026);
}
/// STRATA_GR_V3 (the stream-split hyper-connection read, another summation order) when the user set nothing: ON for a discrete compute capability
+13 -11
View File
@@ -27,6 +27,7 @@
#include "strata/core/layer.hpp"
#include "strata/core/session.hpp"
#include "strata/kernels/sampler.hpp"
#include "strata/kernels/window_limits.hpp"
#include <cuda_runtime.h>
@@ -404,13 +405,13 @@ private:
std::vector<SessionState*> slots_;
bool batch_rec_ = false; ///< record_window is capturing a batch window
int row_base_ = 0; ///< ... its hand-off rows start here (a pipeline group's own rows)
int brow_[8] = {}; ///< ... and row t is slot brow_[t]
int brow_[strata::kernels::kWindowMaxT] = {}; ///< ... and row t is slot brow_[t]
bool last_batch_ = false; ///< the last run was a batch window (set_plan_slot: one group)
std::map<std::vector<int>, cudaGraphExec_t> exec_bm_, commit_bm_; ///< full row layout avoids slot-ID collisions
std::map<std::vector<int>, uint64_t> bm_used_; ///< last use of each captured layout (LRU)
uint64_t bm_tick_ = 0;
size_t batch_graph_limit_ = 0; ///< 0: keep every captured batch graph (0.1.39); N: LRU-evict beyond N layouts
int last_rows_[8] = {}; ///< the slots of the last batch window's rows
int last_rows_[strata::kernels::kWindowMaxT] = {}; ///< the slots of the last batch window's rows
std::vector<int> bkey(const int* rows, int S, int hbase) const { // #871: the doorbell variant has its own graphs
std::vector<int> k = batch_key(rows, S, hbase);
k.push_back(ar_off_ ? 1 : 0);
@@ -439,14 +440,14 @@ private:
std::chrono::steady_clock::duration(b_last_.load(std::memory_order_acquire)));
}
std::chrono::steady_clock::time_point ext_checked_{}; ///< batch_poll's last check with external serving
int32_t b_out_[8] = {};
int32_t b_out_[strata::kernels::kWindowMaxT] = {};
std::vector<strata::kernels::SamplerParams> slot_sp_;
bool sample_rows(int S, std::string& err); ///< the sampled slots' rows of the last batch window
int32_t* h_commitb_ = nullptr; int32_t* m_commitb_ = nullptr; // per slot [1, 0, pos, -1 ..], stride 2 + max_t
int32_t* commitb_ = nullptr;
float* tail_snap_b_ = nullptr; ///< per (slot, QSA layer) indexer tail snapshot
void* arena_b_ = nullptr;
int64_t last_pos_b_[8] = {};
int64_t last_pos_b_[strata::kernels::kWindowMaxT] = {};
bool capture_batch(const int* rows, int S, int hbase, std::string& err);
// STRATA_PIPED_MTP (init_deferred_commit): the saved rows, laid out as the window scratch (global layer index) with
// dc_rows_ rows per layer instead of max_t_; the slots' accepted prefixes (mapped and device, 2 + max_t_ per slot)
@@ -534,7 +535,7 @@ private:
unsigned* qcnt_ = nullptr; ///< S26 STRATA_QFUSE: the HC read's q8_1 group counters (n_embd / 32)
unsigned long long* slot_off_d_ = nullptr; ///< E-6: the slot offsets on the device
int64_t lb_ = 0, le_ = -1; ///< set_stage: the layers this verifier runs (-1: to the last)
int handoff_rows_ = 0; ///< set_handoff_rows (0: kVerifyMaxT)
int handoff_rows_ = 0; ///< set_handoff_rows (0: kVerifyNarrowT)
const float* hand_in_ = nullptr;
float* hand_out_ = nullptr;
uint32_t* hflag_in_h_ = nullptr; ///< set_handoff_flags: the incoming hand-off's ready word (host / device)
@@ -573,7 +574,7 @@ private:
std::vector<unsigned long long> prof_h_;
double prof_sum_[2][kProfPer] = {}; // [GDN / QSA layers][stage]
int64_t prof_windows_ = 0;
double prof_t_sum_[9][2][kProfPer] = {}; // the same per window size T (1..8), for profile_by_t_report
double prof_t_sum_[9][2][kProfPer] = {}; // the same per window size T (1..8: wider windows are not profiled), for profile_by_t_report
int64_t prof_t_win_[9] = {};
bool rs_on_ = false; // STRATA_ROUTE_STATS
unsigned long long* rs_dev_ = nullptr; // [9 T][n_layers][8 rows]: experts of row r that no earlier row of the window routed to
@@ -589,7 +590,7 @@ private:
float* ple_val_ = nullptr;
int last_t_ = 0;
int64_t last_pos0_ = 0;
int32_t last_tokens_[8] = {};
int32_t last_tokens_[strata::kernels::kWindowMaxT] = {};
int64_t n_vocab_ = 0;
cudaStream_t cs_ = nullptr;
cudaStream_t sh_cs_ = nullptr;
@@ -602,9 +603,10 @@ private:
cudaEvent_t df_join_[2] = {};
// [sel_long_][T]: 1 is the long-context QSA select (STRATA_SELECT_LONG=1: multi-CTA top-k, shuffle-free decode
// scores; see run()); without it only [0] is ever captured
cudaGraphExec_t exec_[2][9] = {};
cudaGraphExec_t exec_nr_[2][9] = {}; // #871: the doorbell variant of a stage that is all-resident otherwise
bool launched_[2][9] = {}, launched_nr_[2][9] = {}; ///< this graph has been launched once (early_ple_flag: a cold first launch raises the PLE flag first)
// (per window size T = 1..kWindowMaxT: a solo window is at most 8 rows, but a merge window of a wide batch is not)
cudaGraphExec_t exec_[2][strata::kernels::kWindowMaxT + 1] = {};
cudaGraphExec_t exec_nr_[2][strata::kernels::kWindowMaxT + 1] = {}; // #871: the doorbell variant of a stage that is all-resident otherwise
bool launched_[2][strata::kernels::kWindowMaxT + 1] = {}, launched_nr_[2][strata::kernels::kWindowMaxT + 1] = {}; ///< this graph has been launched once (early_ple_flag: a cold first launch raises the PLE flag first)
int sel_long_ = 0;
// fork 34a35db (STRATA_SIDE_MIXER=1): the GDN gates and the QSA indexer on a branch of the window's graph beside the
// mixer's projections, joined before the recurrence / the attention
@@ -672,7 +674,7 @@ private:
void set_plan_slot(int grp);
bool split_ = false; // opt-in (--spec-split): exact but slower, see the overlap study
std::function<bool(cudaStream_t, std::string&)> pre_launch_; // set_pre_launch
int groups_[9] = {};
int groups_[strata::kernels::kWindowMaxT + 1] = {};
float* h_ymiss_ = nullptr; float* m_ymiss_ = nullptr; // T * k * n_embd
// device
+5
View File
@@ -158,6 +158,11 @@ struct SetArgs {
// their shared-memory tile anyway, so the same values come out; this only saves the F32 read of the same rows by
// every column block of an expert (18-36 reads of 1.8-3.7 MB on a prompt) - MarLin's "small operand cheap".
const uint16_t* x16 = nullptr;
int skip_ge = 0; // (ds4's low-bit GGUF experts) > 0: experts of at least this many rows are the caller's (FP16 GEMMs)
#if defined(STRATA_METAL) // (only on Metal: keeps the CUDA / HIP struct as it was)
const int* hbounds = nullptr; // the same bounds on the host: [n_exp + 1], absolute rows
int total_rows = 0; // the set's rows in all
#endif
};
/// h[local row] = FP16 clamped swiglu of gate and up over x[row_idx[local row]] (F32 rows, ldx floats apart).
void set_gate_up(const SetArgs& a, const float* x, int ldx, const int* row_idx, uint16_t* h16, int ldh, cudaStream_t s);
+5
View File
@@ -17,6 +17,8 @@
// must be different buffers (every block reads the former while one block writes the latter).
#pragma once
#include "strata/kernels/window_limits.hpp"
#include <cstdint>
namespace strata::kernels {
@@ -66,6 +68,9 @@ void fused_gr_quantize_q8_0(const uint16_t* bf16, uint8_t* out, int64_t n_blocks
/// 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;
/// A window of more than kFusedGrMaxT tokens is read in slices of at most kFusedGrMaxT (every token's outputs are
/// strictly its own, so the slices are bitwise the one read); `a` and `xn_scratch` hold up to kFusedGrWideT tokens.
constexpr int kFusedGrWideT = kWindowMaxT;
/// Returns true when it also wrote the q8_1 images (every token's q8_mixed set and this read supports it).
bool 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);
+4
View File
@@ -205,6 +205,10 @@ public:
bool is_open() const;
/// True when `PleIoOptions::lock` was asked for and the OS lock succeeded (false: pages only pre-touched).
bool locked() const;
/// Gives a resident lock back (Mmap mode): the pages stay mapped and cached, but the OS may reclaim them again, so the RAM counts as
/// available. False when the table was not locked. Used by the residency policy when the table's lock would leave the
/// conversation cache no room (#4518).
bool unlock();
uint64_t rows() const;
/// Bytes of the whole table (rows x row bytes) while open; 0 when closed. The residency policy in
/// generate.cpp sizes the table with this before it decides to keep it in RAM.
@@ -23,6 +23,8 @@
// can keep one captured graph per layer with the maximum grid and a device-side count.
#pragma once
#include "strata/kernels/window_limits.hpp"
#include <cstdint>
namespace strata::kernels {
@@ -100,6 +102,29 @@ void moe_hit_add(float* parts, const float* hit_out, const int32_t* dst, const i
void moe_grouped_s2(const unsigned long long* grp_ptr, const int32_t* grp_start, const int32_t* n_groups,
const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, int64_t cap_entries,
const uint8_t* x_q8_0, const float* x_scales, void* scratch, float* out, void* stream);
#if STRATA_VERIFY_MAX_T > 8
/// The same for a window of 9..16 rows (CUDA / HIP engines built with a wide window, window_limits.hpp): `max_group` is the
/// most entries one group can hold = the window's rows (a token routes to an expert once). Up to 8 it is
/// `moe_grouped_s2` (one pass of each kernel); 9..16 adds a second pass over the entries from the 9th on.
void moe_grouped_s2_wide(const unsigned long long* grp_ptr, const int32_t* grp_start, const int32_t* n_groups,
const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, int64_t cap_entries,
const uint8_t* x_q8_0, const float* x_scales, void* scratch, float* out, void* stream,
int max_group);
#endif
/// What a verify window calls: `max_group` is the window's rows. A build with narrow windows (the default, and every SYCL / Metal
/// build, whose kernel libraries have only `moe_grouped_s2`) runs `moe_grouped_s2`: its windows hold at most 8 rows.
inline void moe_grouped_s2_window(const unsigned long long* grp_ptr, const int32_t* grp_start, const int32_t* n_groups,
const int32_t* ent_dst, const int32_t* ent_tok, int64_t cap_groups, int64_t cap_entries,
const uint8_t* x_q8_0, const float* x_scales, void* scratch, float* out, void* stream,
int max_group) {
#if STRATA_VERIFY_MAX_T > 8
moe_grouped_s2_wide(grp_ptr, grp_start, n_groups, ent_dst, ent_tok, cap_groups, cap_entries, x_q8_0, x_scales, scratch, out,
stream, max_group);
#else
(void) max_group;
moe_grouped_s2(grp_ptr, grp_start, n_groups, ent_dst, ent_tok, cap_groups, cap_entries, x_q8_0, x_scales, scratch, out, stream);
#endif
}
/// Plan v0.3 P6: the groups for `moe_grouped_s2` when every expert id is resident at base + id * blob (the MTP
/// layer's 512 experts): counts[0] groups, counts[1] entries; entry dst = routing index, tok = index / k_per_tok.
void moe_group_resident(const int32_t* ids, int n, int k_per_tok, const uint8_t* base, int64_t blob,
+5 -1
View File
@@ -14,11 +14,15 @@
// Everything that varies per window (token ids, `n_keep`) is read from DEVICE memory so the kernels can be captured.
#pragma once
#include "strata/kernels/window_limits.hpp"
#include <cstdint>
namespace strata::kernels {
inline constexpr int kVerifyMaxT = 8;
/// The most rows one verify / batch window holds (8; 16 in the CUDA / HIP builds, window_limits.hpp). A window of up to
/// kVerifyNarrowT rows runs the kernels it always ran.
inline constexpr int kVerifyMaxT = kWindowMaxT;
/// For token t of T: conv over [history(3) | qkv_0 .. qkv_t] -> SiLU -> L2 norm of the q/k heads -> h[t].
/// `history` is NOT written, but with `commit` (one token): it then keeps the token, as gdn_conv_commit. Bitwise
+27
View File
@@ -0,0 +1,27 @@
// include/strata/kernels/window_limits.hpp - how many rows one verify / batch window can hold.
//
// Until 0.2.7 a window held at most 8 rows. The CUDA and HIP engines now hold up to 16 (four pipelined groups of 16
// slots = 64 slots on a four-card split: docs/BATCHING.md), so every compile-time maximum that follows the window
// (the verifier's per-row arrays, the expert pool's per-token tables, the resident plan's one-thread-per-entry block)
// is derived from ONE number. It is a macro because the SYCL and Metal builds compile the same shared headers and
// must not change: they get the default, 8. The CUDA / HIP CMake sets STRATA_VERIFY_MAX_T from the cache variable
// STRATA_WINDOW_MAX_T (8 or 16; -DSTRATA_WINDOW_MAX_T=8 builds an engine whose windows are the 0.2.7 ones).
//
// The rule every user of the number follows: a window of up to kVerifyNarrowT rows runs EXACTLY the code it ran at 8
// (the same kernel instantiations, the same buffer sizes); the wider code is reached only by a window of 9..16 rows.
#pragma once
#ifndef STRATA_VERIFY_MAX_T
#define STRATA_VERIFY_MAX_T 8
#endif
namespace strata::kernels {
/// The window every 0.2.7 kernel was built for: windows of up to this many rows keep their instantiations.
inline constexpr int kVerifyNarrowT = 8;
/// The most rows one window can hold in this build (8 or 16).
inline constexpr int kWindowMaxT = STRATA_VERIFY_MAX_T;
static_assert(kWindowMaxT == 8 || kWindowMaxT == 16, "STRATA_VERIFY_MAX_T is 8 or 16 (the wide kernels are built for 16)");
} // namespace strata::kernels
+148
View File
@@ -0,0 +1,148 @@
// include/strata/program/pipe_probe.hpp - STRATA_P4_TIMING / STRATA_P4_TRACE: where the wall time of a pipelined batch window goes.
//
// Opt-in measurement for the layer-split batch loop (generate.cpp, `pump`). Off, every hook is one predictable branch and
// reads no clock. On:
// STRATA_P4_TIMING=1 per stage: the window's time from launch to done, the cycle between launches, the gap between a
// window done and the next launch, the stage's busy share, the host launch cost; the pump's cost
// per tick, the verdict work per window, how many groups were in flight, the ticks a free stage had
// a group waiting or was starved. Printed to stderr with the batch summary line.
// STRATA_P4_TRACE=PATH one line per window event ("L stage time_us group" at a launch, "D stage time_us 0" when the stage
// is done), flushed on every line so a killed run keeps its trace. "1" or an empty value writes
// ./strata-p4-trace.csv.
// Written for the four-card R9700 investigation (docs/BATCHING.md): it found that the stages run 4 of 4 concurrently in the
// decode phase and that the serial admissions were the idle time.
#pragma once
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace strata::program {
class PipeProbe {
public:
using Clock = std::chrono::steady_clock;
using Time = Clock::time_point;
static constexpr int kMaxStages = 16;
static constexpr int kMaxGroups = 64;
PipeProbe(int n_stages, size_t n_groups)
: n_(std::min(n_stages, kMaxStages)), n_groups_(n_groups) {
timing_ = std::getenv("STRATA_P4_TIMING") != nullptr;
const char* tr = std::getenv("STRATA_P4_TRACE");
if (tr != nullptr) {
const char* path = (tr[0] == '\0' || std::strcmp(tr, "1") == 0) ? "strata-p4-trace.csv" : tr;
trace_ = std::fopen(path, "w");
if (trace_ != nullptr) std::setvbuf(trace_, nullptr, _IONBF, 0);
else std::fprintf(stderr, "strata p4: cannot write the trace %s\n", path);
}
}
~PipeProbe() { if (trace_ != nullptr) std::fclose(trace_); }
PipeProbe(const PipeProbe&) = delete;
PipeProbe& operator=(const PipeProbe&) = delete;
bool on() const { return timing_ || trace_ != nullptr; }
bool timing() const { return timing_; }
Time now() const { return on() ? Clock::now() : Time{}; }
/// The pump's cost: constructed at the top of `pump`, adds its life to the per-tick figure.
struct PumpScope {
PipeProbe* p = nullptr;
Time t0{};
explicit PumpScope(PipeProbe& probe) {
++probe.ticks_;
if (probe.on()) { p = &probe; t0 = Clock::now(); }
}
~PumpScope() { if (p != nullptr) p->pump_ms_ += ms(Clock::now() - t0); }
};
/// The verdict work of a window that reached the last stage.
struct VerdictScope {
PipeProbe* p = nullptr;
Time t0{};
explicit VerdictScope(PipeProbe& probe) {
if (probe.timing_) { p = &probe; t0 = Clock::now(); ++probe.verdict_n_; }
}
~VerdictScope() { if (p != nullptr) p->verdict_ms_ += ms(Clock::now() - t0); }
};
/// Per tick: stages with a window in flight, groups in flight.
void busy(int stages, int groups) { busy_ += stages; inflight_ += groups; }
/// A stage k >= 1 that is free: whether a group is waiting at it (else it is starved by the stage before).
void free_stage(int k, bool group_waiting) {
if (k < 0 || k >= n_) return;
if (group_waiting) ++starve_idle_[k]; else ++starve_none_[k];
}
void span_start(Time t) { span_start_ = t; span_ms_ = 0; }
void span_end(double wall_ms) { span_ms_ = wall_ms; }
void polled(Time before) { if (on()) poll_ms_ += ms(Clock::now() - before); }
/// Stage k reported its window done.
void done(int k, int group) {
if (!on() || k < 0 || k >= n_) return;
const Time t = Clock::now();
if (timing_ && launched_[k]) { win_ms_[k] += ms(t - at_[k]); ++win_n_[k]; }
if (trace_ != nullptr) std::fprintf(trace_, "D %d %.3f 0\n", k, us(t));
done_at_[k] = t;
if (timing_ && group >= 0 && group < kMaxGroups && left_[group] != Time{}) { q_wait_ += ms(t - left_[group]); ++q_n_; }
}
/// Stage k launched `group`'s window; `before` is now() taken before the launch call.
void launched(int k, int group, Time before) {
if (!on() || k < 0 || k >= n_) return;
const Time t = Clock::now();
launch_ms_ += ms(t - before);
at_[k] = t;
launched_[k] = true;
if (group >= 0 && group < kMaxGroups) left_[group] = t;
if (trace_ != nullptr) std::fprintf(trace_, "L %d %.3f %d\n", k, us(t), group);
if (timing_) {
if (last_launch_[k] != Time{}) cycle_ms_[k] += ms(t - last_launch_[k]);
if (done_at_[k] != Time{}) gap_ms_[k] += ms(t - done_at_[k]);
last_launch_[k] = t;
}
}
/// Print and reset (the batch summary line calls it when the loop goes idle).
void flush() {
if (!timing_) return;
const double ticks = (double) std::max(1LL, ticks_);
std::fprintf(stderr, "strata p4: pump %.3f ms/tick over %lld ticks; stage-busy %.2f of %d; group wait %.1f ms;\n",
pump_ms_ / ticks, ticks_, busy_ / ticks, n_, q_n_ ? q_wait_ / (double) q_n_ : 0.0);
for (int k = 0; k < n_; ++k) {
const double n = win_n_[k] ? (double) win_n_[k] : 1.0;
std::fprintf(stderr, "strata p4: s%d windows %lld win %.2f ms cycle %.2f ms gap %.2f ms busy %.0f%% launch %.2f ms\n",
k, win_n_[k], win_ms_[k] / n, cycle_ms_[k] / n, gap_ms_[k] / n,
100.0 * win_ms_[k] / std::max(1e-9, span_ms_), launch_ms_ / n);
}
std::fprintf(stderr, "strata p4: verdict work %.2f ms/window; inflight groups avg %.2f of %zu\n",
verdict_ms_ / std::max(1.0, (double) verdict_n_), inflight_ / ticks, n_groups_);
for (int k = 1; k < n_; ++k)
std::fprintf(stderr, "strata p4: s%d free ticks: with a group waiting %lld, starved %lld\n", k, starve_idle_[k],
starve_none_[k]);
pump_ms_ = poll_ms_ = launch_ms_ = verdict_ms_ = busy_ = inflight_ = q_wait_ = 0;
ticks_ = verdict_n_ = q_n_ = 0;
for (int k = 0; k < kMaxStages; ++k) {
win_ms_[k] = cycle_ms_[k] = gap_ms_[k] = 0;
win_n_[k] = starve_idle_[k] = starve_none_[k] = 0;
}
span_ms_ = 0;
}
private:
static double ms(Clock::duration d) { return std::chrono::duration<double, std::milli>(d).count(); }
static double us(Time t) { return std::chrono::duration<double, std::micro>(t.time_since_epoch()).count(); }
int n_ = 0;
size_t n_groups_ = 0;
bool timing_ = false;
std::FILE* trace_ = nullptr;
double pump_ms_ = 0, poll_ms_ = 0, launch_ms_ = 0, verdict_ms_ = 0, busy_ = 0, inflight_ = 0, q_wait_ = 0, span_ms_ = 0;
long long ticks_ = 0, verdict_n_ = 0, q_n_ = 0;
double win_ms_[kMaxStages] = {}, cycle_ms_[kMaxStages] = {}, gap_ms_[kMaxStages] = {};
long long win_n_[kMaxStages] = {}, starve_idle_[kMaxStages] = {}, starve_none_[kMaxStages] = {};
Time at_[kMaxStages] = {}, last_launch_[kMaxStages] = {}, done_at_[kMaxStages] = {};
bool launched_[kMaxStages] = {};
Time left_[kMaxGroups] = {};
Time span_start_{};
};
} // namespace strata::program
+60 -2
View File
@@ -99,7 +99,8 @@ set(STRATA_METAL_HOST_SOURCES
${S}/spec/suffix_drafter.cpp ${S}/spec/controller.cpp ${S}/spec/draft_policy.cpp ${S}/spec/draft_source.cpp
${S}/kernels/cpu/pool.cpp ${S}/kernels/cpu/expert_layout.cpp ${S}/kernels/cpu/native_expert.cpp
${S}/kernels/cpu/hadamard.cpp ${S}/kernels/cpu/tq_cpu.cpp) # Gyro: the Hadamard rotation, the scalar trellis rows
set_source_files_properties(${S}/core/device.cu ${S}/core/pinned.cu PROPERTIES LANGUAGE CXX)
set_source_files_properties(${S}/core/device.cu ${S}/core/pinned.cu ${DS4}/ds4_vision.cu ${DS4}/vendor/exl3.cu ${DS4}/vendor/moe_win.cu ${DS4}/vendor/sampler.cu
PROPERTIES LANGUAGE CXX)
# The kernel library: Metal implementations (src/engine/*.cpp) and, for every entry point not ported yet, a stub
# that stops the engine with the entry point's name (src/engine/stubs.cpp, generated by tools/gen_stubs.py).
@@ -108,7 +109,7 @@ file(GLOB STRATA_METAL_ENGINE_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_D
add_library(strata_metal_host STATIC ${STRATA_METAL_HOST_SOURCES})
target_include_directories(strata_metal_host PUBLIC ${STRATA_ROOT}/include ${S}/core ${STRATA_ROOT}/third_party/ggml
${STRATA_GGML_DIR}/ggml/include ${STRATA_GGML_DIR}/ggml/src ${STRATA_GGML_DIR}/ggml/src/ggml-cpu)
target_compile_definitions(strata_metal_host PUBLIC STRATA_NATIVE_EXPERTS=1 STRATA_VERSION="0.1.52-metal")
target_compile_definitions(strata_metal_host PUBLIC STRATA_NATIVE_EXPERTS=1 STRATA_VERSION="0.1.54-metal")
target_compile_options(strata_metal_host PRIVATE -Wno-unused-parameter -Wno-deprecated-declarations)
target_link_libraries(strata_metal_host PUBLIC strata_gpu_metal ggml-cpu ggml-base)
@@ -122,3 +123,60 @@ target_link_libraries(strata_metal_kernels PUBLIC strata_metal_host mtl_rt)
add_executable(strata ${S}/program/generate.cpp)
target_link_libraries(strata PRIVATE strata_metal_kernels strata_metal_host strata_metal_kernels)
# --- the DeepSeek engine on this Metal runtime (M5MAX-DEEPSEEK-PLAN.md, steps 3+). The .cu sources are compiled as C++
# through include/metal_compat, which gives them CUDA's names over the Metal runtime; the kernel entry points they call are
# the Metal implementations in src/engine/* or the stubs from tools/gen_stubs.py that stop the engine with the name.
set(DS4 ${STRATA_ROOT}/src/dsv41)
# The DeepSeek engine's sources. Every .cu compiles as C++ through include/metal_compat; the EXL3 legacy path is left out
# (the 2.25bpw text model never calls it) and the vision tower is included because ds4_model.cu references it.
set(STRATA_DS4_SOURCES
${DS4}/ds4_main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/engine/ds4_finish.cpp ${DS4}/ds4_model.cu ${DS4}/ds4_gguf.cu ${DS4}/ds4_iq.cu ${DS4}/ds4_kernels.cu ${DS4}/ds4_tq.cu
${DS4}/ds4_vision.cu
${DS4}/vendor/exl3.cu ${DS4}/vendor/exl3_cpu.cpp ${DS4}/vendor/moe_win.cu ${DS4}/vendor/sampler.cu
${DS4}/ds4_gg_cpu.cpp ${DS4}/ds4_gguf_cpu.cpp ${DS4}/ds4_iq_cpu.cpp ${DS4}/ds4_tq_cpu.cpp ${DS4}/ds4_packinfo.cpp)
set_source_files_properties(
${DS4}/ds4_model.cu ${DS4}/ds4_gguf.cu ${DS4}/ds4_iq.cu ${DS4}/ds4_kernels.cu ${DS4}/ds4_tq.cu ${DS4}/ds4_vision.cu
${DS4}/vendor/exl3.cu ${DS4}/vendor/moe_win.cu ${DS4}/vendor/sampler.cu
PROPERTIES LANGUAGE CXX)
list(APPEND STRATA_DS4_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/engine/exl3_legacy_stub.cpp) # v1.1.8 EXL3: absent on Metal
# The CPU lane for the experts the GPU does not hold (ggml-cpu's NEON row dots): without it every non-resident expert contributes
# zero and the residual stream stays the embedding (measured: layers 0-3 changed nothing, then random tokens).
set_source_files_properties(${DS4}/ds4_gg_cpu.cpp PROPERTIES COMPILE_DEFINITIONS "STRATA_DSV41_TQ_CPU=1")
# The DeepSeek engine's queue, residency and barrier rules (deferred command buffers, the second queue, the residency set shared with
# strata::gpu, ...) live in mtl_rt.mm and gpu/runtime.cpp behind STRATA_DS4_RUNTIME. strata_ds4 compiles its own copy of these two
# files with it; the Qwen engine (strata) and every other target keep the code of 0.2.7 (the macro is off there, so the files read
# exactly as they did). The copies are linked as objects, so they win over the same symbols in the libraries.
add_library(ds4_rt_mm OBJECT src/mtl_rt.mm)
target_include_directories(ds4_rt_mm PRIVATE src ${STRATA_ROOT}/third_party/ggml ${STRATA_ROOT}/include)
target_compile_options(ds4_rt_mm PRIVATE $<$<COMPILE_LANGUAGE:OBJCXX>:-fobjc-arc>)
target_compile_definitions(ds4_rt_mm PRIVATE STRATA_DS4_RUNTIME=1 STRATA_GGML_COMMON_DEFAULT="${GGML_COMMON}"
STRATA_METAL_KERNELS_DEFAULT="${CMAKE_CURRENT_SOURCE_DIR}/kernels")
add_library(ds4_rt_gpu OBJECT src/gpu/runtime.cpp)
target_include_directories(ds4_rt_gpu BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include/metal_compat
${CMAKE_CURRENT_SOURCE_DIR}/include ${STRATA_ROOT}/include)
target_include_directories(ds4_rt_gpu SYSTEM PRIVATE ${METAL_CPP_DIR})
target_compile_definitions(ds4_rt_gpu PRIVATE STRATA_DS4_RUNTIME=1 STRATA_GPU_METAL=1)
add_executable(strata_ds4 ${STRATA_DS4_SOURCES} $<TARGET_OBJECTS:ds4_rt_mm> $<TARGET_OBJECTS:ds4_rt_gpu>)
target_include_directories(strata_ds4 PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include/metal_compat ${STRATA_ROOT}/include ${DS4} ${CMAKE_CURRENT_SOURCE_DIR}/src
${STRATA_ROOT}/third_party/ggml ${STRATA_GGML_DIR}/ggml/include ${STRATA_GGML_DIR}/ggml/src ${STRATA_GGML_DIR}/ggml/src/ggml-cpu)
target_compile_definitions(strata_ds4 PRIVATE STRATA_METAL=1) # the real host CPU lane (ds4_gg_cpu.cpp)
target_compile_options(strata_ds4 PRIVATE -Wno-unused-parameter -Wno-deprecated-declarations -Wno-implicit-function-declaration)
target_link_libraries(strata_ds4 PRIVATE strata_metal_kernels strata_metal_host)
# The commit the binary was built from, readable with --version (the root CMakeLists does the same for the CUDA and HIP builds):
# -DSTRATA_BUILD_ID=..., else BUILD_ID beside the sources (a git archive has no .git), else git, else "unknown".
if(NOT STRATA_BUILD_ID AND EXISTS ${STRATA_ROOT}/BUILD_ID)
file(STRINGS ${STRATA_ROOT}/BUILD_ID STRATA_BUILD_ID LIMIT_COUNT 1)
endif()
if(NOT STRATA_BUILD_ID)
execute_process(COMMAND git -C ${STRATA_ROOT} rev-parse --short=9 HEAD OUTPUT_VARIABLE STRATA_BUILD_ID OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
endif()
if(NOT STRATA_BUILD_ID)
set(STRATA_BUILD_ID unknown)
endif()
set_source_files_properties(${DS4}/ds4_main.cpp PROPERTIES COMPILE_DEFINITIONS "DS4_BUILD_ID=\"${STRATA_BUILD_ID}\"")
# -DSTRATA_DS4_VERSION=0.1.54-metal: the version this DeepSeek engine reports, when its package is released apart from the Qwen engine's
if(STRATA_DS4_VERSION)
set_source_files_properties(${DS4}/ds4_main.cpp PROPERTIES COMPILE_OPTIONS "-USTRATA_VERSION;-DSTRATA_VERSION=\"${STRATA_DS4_VERSION}\"")
endif()
+72
View File
@@ -0,0 +1,72 @@
// metal/include/metal_compat/cublas_v2.h - the cuBLAS surface the dsv41 sources use, over the Metal device runtime.
//
// The engine makes 9 GEMM calls (7 cublasGemmEx, 2 cublasSgemm) for the dense projections, the LM head and the vision
// tower's matmuls. On Metal the dense path is kernels/dense_mm.metal; this header gives the calls the CUDA names so the
// shared sources compile unchanged, and routes them to strata::gpu::dense_gemm(), which the metal host runtime provides.
//
// Compile-audit finding (M5 Max, 2026-10-23): the dsv41 core needs exactly cublasCreate/Destroy/SetStream/SetWorkspace,
// cublasSgemm and cublasGemmEx, plus cublasStatus_t/cublasHandle_t/cublasOperation_t/cublasComputeType_t/cudaDataType_t.
#pragma once
#include <cstdio>
#include <cstdlib>
#include "cuda_runtime.h"
#include "ds4_metal_host.h" // the kernels that have a Metal dispatch
typedef void* cublasHandle_t;
enum cublasStatus_t { CUBLAS_STATUS_SUCCESS = 0, CUBLAS_STATUS_NOT_INITIALIZED = 1, CUBLAS_STATUS_ALLOC_FAILED = 3,
CUBLAS_STATUS_INVALID_VALUE = 7, CUBLAS_STATUS_ARCH_MISMATCH = 8, CUBLAS_STATUS_NOT_SUPPORTED = 14 };
enum cublasOperation_t { CUBLAS_OP_N = 0, CUBLAS_OP_T = 1, CUBLAS_OP_C = 2 };
enum cublasComputeType_t { CUBLAS_COMPUTE_16F = 64, CUBLAS_COMPUTE_32F = 68, CUBLAS_COMPUTE_32F_FAST_16F = 74 };
enum cublasGemmAlgo_t { CUBLAS_GEMM_DEFAULT = -1, CUBLAS_GEMM_DEFAULT_TENSOR_OP = 99 };
enum cudaDataType_t { CUDA_R_32F = 0, CUDA_R_16F = 2, CUDA_R_16BF = 14, CUDA_R_8F_E4M3 = 28 };
using cudaDataType = cudaDataType_t; // the engine names it both ways
static inline cublasStatus_t cublasCreate(cublasHandle_t* h) {
*h = (void*) 1; // the Metal runtime is a singleton: the handle carries no state
return CUBLAS_STATUS_SUCCESS;
}
static inline cublasStatus_t cublasDestroy(cublasHandle_t) { return CUBLAS_STATUS_SUCCESS; }
static inline cublasStatus_t cublasSetStream(cublasHandle_t, cudaStream_t) { return CUBLAS_STATUS_SUCCESS; }
static inline cublasStatus_t cublasSetWorkspace(cublasHandle_t, void*, size_t) { return CUBLAS_STATUS_SUCCESS; }
// C = alpha * op(A) * op(B) + beta * C, all row-major floats.
// NOT WIRED YET: the metal host runtime has no dense GEMM entry point. This aborts with a message instead of returning a
// wrong result, so the shared sources compile and a run fails where the work is, not somewhere confusing. The real
// implementation drives kernels/dense_mm.metal (the kernel exists and passes its parity test).
[[noreturn]] static inline void strata_metal_dense_gemm_missing(const char* what) {
std::fprintf(stderr, "strata metal: %s is not wired to kernels/dense_mm.metal yet\n", what);
std::abort();
}
static inline cublasStatus_t cublasSgemm(cublasHandle_t, cublasOperation_t opA, cublasOperation_t opB, int m, int n, int k,
const float* alpha, const float* A, int lda, const float* B, int ldb,
const float* beta, float* C, int ldc) {
// the mHC mixes (ds4_model.cu:2737): C = A^T B, F32, alpha 1, beta 0
if (opA == CUBLAS_OP_T && opB == CUBLAS_OP_N && alpha && beta && *alpha == 1.0f && *beta == 0.0f &&
strata::dsv41::metal_host::sgemm_tn(A, B, C, m, n, k, lda, ldb, ldc)) {
return CUBLAS_STATUS_SUCCESS;
}
strata_metal_dense_gemm_missing("cublasSgemm");
}
// the mixed-precision form: any argument list, because the engine passes cuBLAS's full 19-argument spelling; the body
// names kernels/dense_mm.metal, which is where the real implementation goes.
// the chunk path's call (F16 weights [m][k] x F16 rows [n][k] -> F32 rows [n][m]): the MPP tile GEMM
static inline cublasStatus_t cublasGemmEx(cublasHandle_t, cublasOperation_t ta, cublasOperation_t tb, int m, int n, int k,
const void* alpha, const void* A, cudaDataType_t at, int lda, const void* B,
cudaDataType_t bt, int ldb, const void* beta, void* C, cudaDataType_t ct, int ldc,
cublasComputeType_t, cublasGemmAlgo_t) {
if (ta == CUBLAS_OP_T && tb == CUBLAS_OP_N && at == CUDA_R_16F && bt == CUDA_R_16F && ct == CUDA_R_32F && lda == k &&
strata::dsv41::metal_host::gemm_f16_nt((const uint16_t*) A, (const uint16_t*) B, ldb, (float*) C, ldc, n, m, k, *(const float*) alpha,
*(const float*) beta))
return CUBLAS_STATUS_SUCCESS;
strata_metal_dense_gemm_missing("cublasGemmEx (this operand combination)");
}
template <typename... A>
static inline cublasStatus_t cublasGemmEx_unsupported(A...) {
strata_metal_dense_gemm_missing("cublasGemmEx");
}
+59
View File
@@ -0,0 +1,59 @@
// metal/include/metal_compat/cuda_bf16.h - the handful of CUDA bf16/fp8 names the dsv41 sources use, on Metal.
//
// Only what the engine touches (found by compiling src/dsv41/*.cu against this directory, 2026-10-23): the storage typedef
// and four conversions. On a Mac the bf16 storage is a uint16_t and the conversion is the standard round-to-nearest-even
// truncation CUDA's __float2bfloat16_rn does; fp8 is E4M3 with the same rounding.
#pragma once
#include "cuda_fp16.h" // __half_raw, which the fp8 conversions return
#include <cstdint>
#include <cstring>
typedef uint16_t __nv_bfloat16;
typedef uint16_t __nv_bfloat162;
typedef uint8_t __nv_fp8_storage_t;
static inline uint16_t strata_f32_to_bf16_rn(float f) {
uint32_t u;
std::memcpy(&u, &f, sizeof u);
if ((u & 0x7fffffffu) > 0x7f800000u) return (uint16_t) ((u >> 16) | 0x0040u); // NaN keeps a payload
uint32_t r = (u + 0x7fffu + ((u >> 16) & 1u)) >> 16;
return (uint16_t) r;
}
static inline __nv_bfloat16 __float2bfloat16_rn(float f) { return strata_f32_to_bf16_rn(f); }
static inline uint16_t __bfloat16_as_ushort(__nv_bfloat16 v) { return v; }
// E4M3 (no infinities, max 448), round to nearest even - the inverse of the half conversion the engine also uses
static inline __nv_fp8_storage_t __nv_cvt_float_to_fp8(float f, int, int) {
if (f != f) return 0x7f;
float a = f < 0 ? -f : f;
int sign = f < 0 ? 0x80 : 0;
if (a == 0.0f) return (__nv_fp8_storage_t) sign;
if (a >= 448.0f) return (__nv_fp8_storage_t) (sign | 0x7e);
int e = 0;
while (a >= 2.0f) { a *= 0.5f; ++e; }
while (a < 1.0f) { a *= 2.0f; --e; }
// a in [1,2): 3 mantissa bits, bias 7
float scaled = (a - 1.0f) * 8.0f;
int m = (int) (scaled + 0.5f);
int biased = e + 7;
if (m == 8) { m = 0; ++biased; }
if (biased <= 0) return (__nv_fp8_storage_t) sign; // subnormal -> zero here
if (biased >= 15) return (__nv_fp8_storage_t) (sign | 0x7e); // clamp to 448
return (__nv_fp8_storage_t) (sign | (biased << 3) | m);
}
static inline uint16_t __nv_cvt_fp8_to_halfraw(__nv_fp8_storage_t v, int) {
// half (IEEE binary16) from E4M3: the engine only reads these back for the vision tower's tables
const int sign = (v & 0x80) ? 0x8000 : 0;
const int e = (v >> 3) & 0xf, m = v & 0x7;
if (e == 0 && m == 0) return (uint16_t) sign;
int he, hm;
if (e == 0) { he = 0; hm = m << 7; }
else { he = e - 7 + 15; hm = m << 7; }
return (uint16_t) (sign | (he << 10) | hm);
}
// the conversion modes __nv_cvt_float_to_fp8 / __nv_cvt_fp8_to_halfraw take (CUDA spells them as an enum)
enum __nv_fp8_interpretation_t { __NV_E4M3 = 0, __NV_E5M2 = 1 };
enum __nv_saturation_t { __NV_NOSAT = 0, __NV_SATFINITE = 1 };
@@ -0,0 +1,165 @@
#ifndef __grid_constant__
#define __grid_constant__
#endif
#ifndef __device_builtin__
#define __device_builtin__
#endif
// cuda_device_builtins.h - the CUDA device built-ins, declared for the HOST compile only.
//
// The DeepSeek kernels are compiled twice in this port: as C++ here, to enumerate exactly which API the device code uses,
// and as MSL (kernels/*.metal) where the real work happens. These declarations let the first pass run; nothing here is a
// working kernel. Metal spells the same things thread_position_in_grid, threadgroup_barrier, simd_shuffle and so on.
#pragma once
#include <cstdint>
struct dim3 {
unsigned x, y, z;
dim3(unsigned _x = 1, unsigned _y = 1, unsigned _z = 1) : x(_x), y(_y), z(_z) {}
};
struct strata_dim3 { unsigned x, y, z; };
static strata_dim3 threadIdx{0, 0, 0}, blockIdx{0, 0, 0}, blockDim{1, 1, 1}, gridDim{1, 1, 1};
static inline void __syncthreads() {}
static inline void __syncwarp(unsigned = 0xffffffffu) {}
static inline unsigned __ballot_sync(unsigned, int) { return 0; }
static inline int __popc(unsigned v) { return __builtin_popcount(v); }
static inline int __ffs(unsigned v) { return __builtin_ffs((int) v); }
// the bit and integer intrinsics the block decoders use. Each is the exact CUDA semantics, computed on the host here; the
// MSL kernels implement the same operations with Metal's own select/rotate/permute built-ins.
static inline unsigned __byte_perm(unsigned x, unsigned y, unsigned s) {
unsigned out = 0;
const unsigned long long src = ((unsigned long long) y << 32) | x;
for (int i = 0; i < 4; ++i) {
const unsigned sel = (s >> (4 * i)) & 0x7u;
out |= (unsigned) ((src >> (8 * sel)) & 0xffu) << (8 * i);
}
return out;
}
static inline int __mul24(int a, int b) { return a * b; }
static inline unsigned __umul24(unsigned a, unsigned b) { return a * b; }
static inline unsigned __brev(unsigned v) { return __builtin_bitreverse32(v); }
static inline int __clz(int v) { return v ? __builtin_clz((unsigned) v) : 32; }
static inline int __clzll(long long v) { return v ? __builtin_clzll((unsigned long long) v) : 64; }
static inline float __fdividef(float a, float b) { return a / b; }
static inline float __saturatef(float a) { return a < 0.0f ? 0.0f : (a > 1.0f ? 1.0f : a); }
static inline float __int_as_float(int v) { float f; __builtin_memcpy(&f, &v, 4); return f; }
static inline int __float_as_int(float f) { int v; __builtin_memcpy(&v, &f, 4); return v; }
static inline unsigned __float_as_uint(float f) { unsigned v; __builtin_memcpy(&v, &f, 4); return v; }
static inline float __uint_as_float(unsigned v) { float f; __builtin_memcpy(&f, &v, 4); return f; }
static inline void __threadfence() {}
static inline void __threadfence_block() {}
static inline float atomicAdd(float* p, float v) { const float o = *p; *p = o + v; return o; }
static inline int atomicAdd(int* p, int v) { const int o = *p; *p = o + v; return o; }
static inline unsigned atomicAdd(unsigned* p, unsigned v) { const unsigned o = *p; *p = o + v; return o; }
static inline int atomicMax(int* p, int v) { const int o = *p; if (v > o) *p = v; return o; }
static inline int atomicCAS(int* p, int c, int v) { const int o = *p; if (o == c) *p = v; return o; }
// the SIMD video instructions (packed 8-bit lanes) the i-quant block decoders use: on CUDA these are one instruction, on
// Apple Silicon NEON has them directly (vsubq_u8, vpaddlq / vdotq_s32) and the MSL kernels use those. Here they are the
// exact per-lane semantics, so the host compile reproduces the same numbers.
static inline unsigned __vsub4(unsigned a, unsigned b) {
unsigned out = 0;
for (int i = 0; i < 4; ++i) {
const int d = (int) ((a >> (8 * i)) & 0xffu) - (int) ((b >> (8 * i)) & 0xffu);
out |= (unsigned) ((d < 0 ? 0 : d) & 0xff) << (8 * i); // unsigned saturated
}
return out;
}
static inline unsigned __vsubss4(unsigned a, unsigned b) {
unsigned out = 0;
for (int i = 0; i < 4; ++i) {
int d = (int) ((a >> (8 * i)) & 0xffu) - (int) ((b >> (8 * i)) & 0xffu);
if (d < -128) d = -128;
if (d > 127) d = 127;
out |= (unsigned) (d & 0xff) << (8 * i);
}
return out;
}
static inline unsigned __vadd4(unsigned a, unsigned b) {
unsigned out = 0;
for (int i = 0; i < 4; ++i) {
const int d = (int) ((a >> (8 * i)) & 0xffu) + (int) ((b >> (8 * i)) & 0xffu);
out |= (unsigned) ((d > 255 ? 255 : d) & 0xff) << (8 * i);
}
return out;
}
static inline unsigned __vaddss4(unsigned a, unsigned b) {
unsigned out = 0;
for (int i = 0; i < 4; ++i) {
int d = (int) ((a >> (8 * i)) & 0xffu) + (int) ((b >> (8 * i)) & 0xffu);
if (d > 127) d = 127;
out |= (unsigned) (d & 0xff) << (8 * i);
}
return out;
}
static inline unsigned __vcmpeq4(unsigned a, unsigned b) {
unsigned out = 0;
for (int i = 0; i < 4; ++i) {
const unsigned ea = (a >> (8 * i)) & 0xffu, eb = (b >> (8 * i)) & 0xffu;
out |= (ea == eb ? 0xffu : 0u) << (8 * i);
}
return out;
}
static inline unsigned __vcmpgtu4(unsigned a, unsigned b) {
unsigned out = 0;
for (int i = 0; i < 4; ++i) {
const unsigned ea = (a >> (8 * i)) & 0xffu, eb = (b >> (8 * i)) & 0xffu;
out |= (ea > eb ? 0xffu : 0u) << (8 * i);
}
return out;
}
static inline int __dp4a(unsigned a, unsigned b, int c) {
for (int i = 0; i < 4; ++i) c += (int) (signed char) ((a >> (8 * i)) & 0xffu) * (int) (signed char) ((b >> (8 * i)) & 0xffu);
return c;
}
// the remaining scalar math helpers CUDA provides and the decoders use (round-to-nearest, plus min/max for integers - CUDA
// brings those in from its own headers, the C++ standard library does not for every type)
static inline float __fsub_rn(float a, float b) { return a - b; }
static inline float __fdiv_rn(float a, float b) { return a / b; }
static inline float __frcp_rn_(float a) { return 1.0f / a; }
template <typename T> static inline T strata_min(T a, T b) { return a < b ? a : b; }
template <typename T> static inline T strata_max(T a, T b) { return a > b ? a : b; }
// the last names the kernel sources use (a full-list compile found twelve families, 2026-10-23): the grid type above, a
// system fence, the funnel shift, the hardware transcendentals and the read-only/volatile loads.
static inline void __threadfence_system() {}
static inline unsigned __funnelshift_r(unsigned lo, unsigned hi, unsigned shift) {
const unsigned long long v = ((unsigned long long) hi << 32) | lo;
return (unsigned) ((v >> (shift & 31)) & 0xffffffffu);
}
static inline unsigned __funnelshift_l(unsigned lo, unsigned hi, unsigned shift) {
const unsigned long long v = ((unsigned long long) hi << 32) | lo;
return (unsigned) (((v << (shift & 31)) >> 32) & 0xffffffffu);
}
static inline float rsqrtf(float a) { return 1.0f / __builtin_sqrtf(a); }
static inline float __expf(float a) { return __builtin_expf(a); }
static inline float __logf(float a) { return __builtin_logf(a); }
static inline float __sinf(float a) { return __builtin_sinf(a); }
static inline float __cosf(float a) { return __builtin_cosf(a); }
static inline void sincosf(float a, float* s, float* c) { *s = __builtin_sinf(a); *c = __builtin_cosf(a); }
static inline int __float2int_rn(float a) { return (int) __builtin_roundf(a); }
static inline int __float2int_rz(float a) { return (int) a; }
static inline int __float2int_rd(float a) { return (int) __builtin_floorf(a); }
static inline int __float2int_ru(float a) { return (int) __builtin_ceilf(a); }
template <typename T> static inline T __ldg(const T* p) { return *p; }
template <typename T> static inline T __ldcv(const T* p) { return *p; }
// shuffles: one template each, so int/unsigned/float/short all bind (CUDA has overloads; a template is the same thing here)
template <typename T> static inline T __shfl_xor_sync(unsigned, T v, int) { return v; }
template <typename T> static inline T __shfl_down_sync(unsigned, T v, int) { return v; }
template <typename T> static inline T __shfl_up_sync(unsigned, T v, int) { return v; }
template <typename T> static inline T __shfl_sync(unsigned, T v, int) { return v; }
// the integer multiply-high and the bitwise atomics the sampler uses
static inline unsigned __umulhi(unsigned a, unsigned b) { return (unsigned) (((unsigned long long) a * b) >> 32); }
static inline int __mulhi(int a, int b) { return (int) (((long long) a * b) >> 32); }
static inline unsigned atomicOr(unsigned* p, unsigned v) { const unsigned o = *p; *p = o | v; return o; }
static inline unsigned atomicAnd(unsigned* p, unsigned v) { const unsigned o = *p; *p = o & v; return o; }
static inline unsigned atomicXor(unsigned* p, unsigned v) { const unsigned o = *p; *p = o ^ v; return o; }
static inline unsigned atomicExch(unsigned* p, unsigned v) { const unsigned o = *p; *p = v; return o; }
static inline unsigned atomicInc(unsigned* p, unsigned v) { const unsigned o = *p; *p = (o >= v ? 0 : o + 1); return o; }
static inline unsigned atomicDec(unsigned* p, unsigned v) { const unsigned o = *p; *p = (o == 0 || o > v ? v : o - 1); return o; }
+99
View File
@@ -0,0 +1,99 @@
// metal/include/metal_compat/cuda_fp16.h - the half-precision surface the dsv41 sources use, on a Mac.
//
// The engine touches exactly these (counted by compiling src/dsv41/*.cu against this directory, M5 Max 2026-10-23):
// __half 16, __half2 11, __half_raw 3, __half_as_ushort 8,
// __float2half_rn 17, __half2float 18, __half22float2 6,
// the round-to-nearest float helpers (__fmul_rn, __fadd_rn, __fmaf_rn, __frcp_rn, __fsqrt_rn),
// and the plain half/half2 typedefs ggml-common.h uses.
// On Apple Silicon half is IEEE binary16 too, so the storage is a uint16_t and the conversions are the standard
// round-to-nearest-even ones - the same bits CUDA produces.
#pragma once
#include "vector_types.h"
#include <cstdint>
#include <cstring>
struct __half_raw {
uint16_t x;
__half_raw() = default;
constexpr __half_raw(uint16_t bits) : x(bits) {}
};
struct __half {
uint16_t x;
__half() = default;
__half(const __half_raw& r) : x(r.x) {}
operator __half_raw() const { return __half_raw{x}; }
};
struct __half2 {
uint16_t x, y;
};
typedef __half half;
typedef __half2 half2;
static inline uint16_t strata_f32_to_f16_bits(float f) {
uint32_t u;
std::memcpy(&u, &f, sizeof u);
const uint32_t sign = (u >> 16) & 0x8000u;
const uint32_t mag = u & 0x7fffffffu;
if (mag >= 0x7f800000u) return (uint16_t) (sign | (mag > 0x7f800000u ? 0x7e00u : 0x7c00u));
if (mag >= 0x477ff000u) return (uint16_t) (sign | 0x7c00u);
const uint32_t r = (mag + 0x0fffu + ((mag >> 13) & 1u)) >> 13;
if (r >= 0x7c00u) return (uint16_t) (sign | 0x7c00u);
return (uint16_t) (sign | r);
}
static inline float strata_f16_bits_to_f32(uint16_t h) {
const uint32_t sign = (uint32_t) (h & 0x8000u) << 16;
const uint32_t e = (h >> 10) & 0x1fu, m = h & 0x3ffu;
uint32_t bits;
if (e == 0) {
if (m == 0) bits = sign;
else {
const float v = (float) m * (1.0f / 16777216.0f);
return sign ? -v : v;
}
} else if (e == 31) {
bits = sign | 0x7f800000u | (m << 13);
} else {
bits = sign | ((e - 15 + 127) << 23) | (m << 13);
}
float out;
std::memcpy(&out, &bits, sizeof out);
return out;
}
static inline __half __float2half_rn(float f) { __half h; h.x = strata_f32_to_f16_bits(f); return h; }
static inline __half __float2half(float f) { return __float2half_rn(f); }
static inline float __half2float(__half h) { return strata_f16_bits_to_f32(h.x); }
static inline uint16_t __half_as_ushort(__half h) { return h.x; }
static inline __half __ushort_as_half(uint16_t u) { __half h; h.x = u; return h; }
static inline float2 __half22float2(__half2 h) { return make_float2(strata_f16_bits_to_f32(h.x), strata_f16_bits_to_f32(h.y)); }
static inline __half2 __floats2half2_rn(float a, float b) {
__half2 h;
h.x = strata_f32_to_f16_bits(a);
h.y = strata_f32_to_f16_bits(b);
return h;
}
// round-to-nearest float helpers: the FP ops here are IEEE and un-fused, which is what these mean
static inline float __fmul_rn(float a, float b) { return a * b; }
static inline float __fadd_rn(float a, float b) { return a + b; }
static inline float __fmaf_rn(float a, float b, float c) { return a * b + c; }
static inline float __frcp_rn(float a) { return 1.0f / a; }
static inline float __fsqrt_rn(float a) { return __builtin_sqrtf(a); }
// the half2 pack/unpack and arithmetic helpers the trellis and vision paths use
static inline __half2 __halves2half2(__half lo, __half hi) { __half2 h; h.x = lo.x; h.y = hi.x; return h; }
static inline __half2 __half2half2(__half v) { __half2 h; h.x = v.x; h.y = v.x; return h; }
static inline __half __low2half(__half2 h) { __half o; o.x = h.x; return o; }
static inline __half __high2half(__half2 h) { __half o; o.x = h.y; return o; }
static inline float __low2float(__half2 h) { return strata_f16_bits_to_f32(h.x); }
static inline float __high2float(__half2 h) { return strata_f16_bits_to_f32(h.y); }
static inline __half2 __hadd2(__half2 a, __half2 b) { return __floats2half2_rn(__half2float(__low2half(a)) + __half2float(__low2half(b)), __half2float(__high2half(a)) + __half2float(__high2half(b))); }
static inline __half2 __hmul2(__half2 a, __half2 b) { return __floats2half2_rn(__half2float(__low2half(a)) * __half2float(__low2half(b)), __half2float(__high2half(a)) * __half2float(__high2half(b))); }
static inline __half2 __hfma2(__half2 a, __half2 b, __half2 c) { return __floats2half2_rn(__half2float(__low2half(a)) * __half2float(__low2half(b)) + __half2float(__low2half(c)), __half2float(__high2half(a)) * __half2float(__high2half(b)) + __half2float(__high2half(c))); }
static inline __half2 __hneg2(__half2 a) { __half2 h; h.x = a.x ^ 0x8000u; h.y = a.y ^ 0x8000u; return h; }
+4
View File
@@ -0,0 +1,4 @@
// metal/include/metal_compat/cuda_fp8.h - the fp8 names the engine includes; the definitions live beside the bf16 ones.
#pragma once
#include "cuda_bf16.h"
+31
View File
@@ -0,0 +1,31 @@
// cuda_launch.h - the <<<grid, block, shared, stream>>> launch syntax, as a call.
//
// A C++ compiler cannot parse CUDA's launch syntax, and the Metal port replaces every launch with a dispatch anyway (the grid
// becomes a threadgroup count, the block a threadgroup size). strata_ds4_launch() is that call: the kernel first, then the
// launch shape, then the kernel's own arguments - every parameter generic, because the shapes vary (dim3, plain ints, sizes,
// and the stream type), and the point of this header is to make the sources parse, not to type-check the port.
//
// Until a kernel has a Metal implementation the call stops the run naming the kernel, so the engine reports the first one it
// needs and the port proceeds one kernel at a time.
#pragma once
#include <cstdio>
#include <cstdlib>
template <typename K, typename... A>
static inline void strata_ds4_launch(K kernel, A&&...) {
static_assert(sizeof(K) > 0, "kernel");
std::fprintf(stderr, "strata metal: a dsv41 kernel launch has no Metal dispatch yet\n");
std::abort();
}
// the call sites sit inside namespaces whose first argument would win argument-dependent lookup: force the global one
// the call sites carry their own location, so a run names the exact launch that needs a dispatch
#define strata_ds4_launch(...) ::strata_ds4_launch_at(__FILE__, __LINE__, __VA_ARGS__)
// the macro adds the call site's location, so a run names the exact launch that still needs a Metal dispatch
template <typename K, typename... A>
static inline void strata_ds4_launch_at(const char* file, int line, K, A&&...) {
std::fprintf(stderr, "strata metal: kernel launch with no Metal dispatch at %s:%d\n", file, line);
std::abort();
}
+75
View File
@@ -1,3 +1,32 @@
// The CUDA function-space qualifiers and the few launch/kernel spellings the shared sources use. On Metal they say
#include "cuda_launch.h"
// nothing: a "kernel" here is an MSL function compiled at run time and dispatched by the host runtime, so the qualifiers
// are empty and __forceinline__ is plain inline. Part of the metal_compat layer (M5 Max, 2026-10-23).
#ifndef __host__
#define __host__
#endif
#ifndef __device__
#define __device__
#endif
#ifndef __global__
#define __global__
#endif
#ifndef __shared__
#define __shared__
#endif
#ifndef __constant__
#define __constant__
#endif
#ifndef __forceinline__
#define __forceinline__ inline
#endif
#ifndef __launch_bounds__
#define __launch_bounds__(...)
#endif
#ifndef __restrict__
#define __restrict__
#endif
// metal/include/metal_compat/cuda_runtime.h - the CUDA runtime names the host files use, on Metal.
//
// The HIP build's approach (include/strata/hip_compat/cuda_runtime.h maps every cudaX the host code calls to hipX),
@@ -8,6 +37,27 @@
// uses another one fails to compile on Metal, which is the point.
#pragma once
// CUDA's alignment qualifier
#ifndef __align__
#define __align__(n) alignas(n)
#endif
#include "cuda_device_builtins.h"
#include <cmath>
using std::fmaxf;
using std::fminf;
using std::sqrtf;
using std::fabsf;
using std::powf;
using std::expf;
using std::logf;
// CUDA's headers put min/max in scope for integers; the sources rely on it
template <typename T> static inline T min(T a, T b) { return strata_min(a, b); }
template <typename T> static inline T max(T a, T b) { return strata_max(a, b); }
#include <cstddef>
#include <cstdint>
@@ -45,6 +95,12 @@ inline constexpr unsigned cudaEventDefault = strata::gpu::kEventDefault;
inline constexpr unsigned cudaEventDisableTiming = strata::gpu::kEventDisableTiming;
inline constexpr unsigned cudaEventBlockingSync = strata::gpu::kEventBlockingSync;
inline constexpr unsigned cudaStreamNonBlocking = strata::gpu::kStreamNonBlocking;
enum cudaStreamCaptureStatus { cudaStreamCaptureStatusNone = 0, cudaStreamCaptureStatusActive = 1, cudaStreamCaptureStatusInvalidated = 2 };
static inline strata::gpu::Error cudaStreamIsCapturing(strata::gpu::Stream, cudaStreamCaptureStatus* st) {
if (st) *st = cudaStreamCaptureStatusNone;
return strata::gpu::kSuccess;
}
enum cudaStreamCaptureMode { cudaStreamCaptureModeGlobal = 0, cudaStreamCaptureModeThreadLocal = 1, cudaStreamCaptureModeRelaxed = 2 };
// (the compute capability is asked by the 0.1.45 Turing defaults in verify.cpp: no answer here, which reads as "not a Turing card")
enum cudaDeviceAttr {
@@ -79,6 +135,11 @@ inline cudaError_t cudaDeviceEnablePeerAccess(int p, unsigned f) { return strata
inline cudaError_t cudaDeviceDisablePeerAccess(int) { return cudaSuccess; } // a Mac has one GPU: nothing to disable
// ---- memory and copies
// the deferred command buffer mtl_rt batches the port's dispatches into: a copy (or any host read of a device result)
// has to commit it first, or it reads memory the GPU has not written yet. Defined in ds4_finish.cpp.
extern "C" void ds4_metal_flush();
extern "C" void ds4_metal_kick();
template <class T> inline cudaError_t cudaMalloc(T** p, size_t n) { return strata::gpu::malloc_device(p, n); }
inline cudaError_t cudaFree(void* p) { return strata::gpu::free_device(p); }
template <class T> inline cudaError_t cudaHostAlloc(T** p, size_t n, unsigned f) { return strata::gpu::host_alloc(p, n, f); }
@@ -141,3 +202,17 @@ inline cudaError_t cudaGraphLaunch(cudaGraphExec_t x, cudaStream_t s) { return s
inline cudaError_t cudaGraphUpload(cudaGraphExec_t x, cudaStream_t s) { return strata::gpu::graph_upload(x, s); }
inline cudaError_t cudaGraphDestroy(cudaGraph_t g) { return strata::gpu::graph_destroy(g); }
inline cudaError_t cudaGraphExecDestroy(cudaGraphExec_t x) { return strata::gpu::graph_exec_destroy(x); }
// cudaMemcpy2D(dst, dpitch, src, spitch, width, height, kind): a strided copy, used by the vision tower's patch rows and
// the Engram tables' column slices. The Metal runtime has no pitched copy yet, so this reports failure rather than
// copying nothing silently.
inline strata::gpu::Error cudaMemcpy2D(void*, size_t, const void*, size_t, size_t, size_t, strata::gpu::CopyKind) {
return strata::gpu::kErrorUnsupported; // cudaMemcpy2D_unimplemented_marker
}
// occupancy query: the Metal runtime decides its own threadgroup limits, so answer "one block" and let the caller size grids
template <typename K, typename... A>
static inline strata::gpu::Error cudaOccupancyMaxActiveBlocksPerMultiprocessor(int* n, K&&, int, A&&...) {
if (n) *n = 1;
return strata::gpu::kSuccess;
}
+111
View File
@@ -0,0 +1,111 @@
// metal/include/metal_compat/ds4_metal_host.h - the dsv41 kernels that have a Metal dispatch.
//
// GENERATED from metal/src/engine/ds4_host.cpp and ds4_finish.cpp, which are the source of truth: do not hand-edit the
// declaration block, regenerate it (see RIGS/m1002.md).
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace strata::gpu {
struct StreamImpl;
using Stream = StreamImpl*;
} // namespace strata::gpu
namespace strata::dsv41::metal_host {
// ds4_kernels.cu:1894 finish_kernel<false> - the engine's Finish, as ds4_kernels.cuh declares it
struct FinishIn {
const float* w;
float eps;
int rd, pos, inverse, vpp, pos_step;
const float* freqs;
int quant;
const uint16_t* dst;
long long dst_stride;
uint8_t* pdst;
long long pdst_stride;
};
bool embed(const uint16_t* rows, int T, float* h, int dim, int hc, void*);
bool engram_dequant_tok(const uint8_t* src, size_t per, int cols, int T, int hd, float* out, int fmt);
bool ext_to_ring(const void* ext, void* ring, long long p0, int n, int T, int d, size_t row_bytes, void* backup);
bool finish(float* x, int n_vec, int len, long long stride, const FinishIn& f);
bool gemv_f32(const float* W, const float* x, int n, int k, int T, float* y, int ldy);
bool hc_mix(const float* h, const float* fn, int hcdim, int n_mix, int T, float eps, float* mixes);
bool hc_mix_scale(const float* h, int hcdim, int n_mix, int T, float eps, float* mixes);
bool hc_post(const float* y, float* h, const float* post, const float* comb, int dim, int hc, int T);
bool hc_post_v(const float* y, const float* res, const float* post, const float* comb, int dim, int hc, int T, float* out);
bool hc_pre_norm(const float* h, const float* mix, const float* w, int dim, int hc, int T, float eps, float* x);
bool hc_split(const float* mixes, const float* scale, const float* base, int hc, int iters, float hc_eps, int T, float* pre, float* post, float* comb);
bool hc_mix_split(const float* h, const float* fn, int hcdim, int n_mix, int T, float eps, float* mixes, const float* scale, const float* base, int hc, int iters, float hc_eps, float* pre, float* post, float* comb);
bool init_pre(float* pre, int T, void*);
bool iq_mv(const void* jobs, int nj);
bool moe_combine(void* acc, const void* y, const int* pos, const float* w_, int T, int k, int r0, int r1, int dim);
bool moe_sort(const int* idx, int T, int k, const int* vs, int n_vs, int* bounds, int* row_tok, int* pos);
bool moe_win_group_cpu(const void* plan_ptr, void* grp_ptr, void* grp_start, void* grp_ent, void* n_groups, int T, int k);
bool mx_mv(const void* jobs, int nj, int max_n, size_t job_bytes, int fmt);
bool q8_quant_rows(const float* x, size_t ldx, int rows, int k, int8_t* q, float* d);
bool ring_to_ext(const void* ring, void* ext, long long p0, int n, int d, size_t row_bytes);
bool route_publish(const float* logits, const float* bias, int n, int k, int T, float route_scale, const void* vram, int* idx, float* w, void* plan_ptr, const float* bias_vl, unsigned vl);
bool sgemm_tn(const float* A, const float* B, float* C, int m, int nt, int k, int lda, int ldb, int ldc);
bool rows_f16(const float* x, int ldx, const int* idx, int nrows, int ncols, uint16_t* out);
uint64_t zc_map(const void* p, size_t bytes); // zero-copy weights: the GPU address of host memory, 0 if not mappable
bool q8_quant_multi(const void* specs, int n); // {const float* x; uint64 ldx; uint32 rows, k; int8* q; float* d} each
bool iq_set_gu(const uint8_t* wbase, size_t stride, const size_t* tr, const int* hbounds, int n_exp, int total_rows, int n_embd, int n_ff, float limit, const float* xf, int ldx, const int* idx, uint16_t* h, int ldh, int tg, int td);
bool iq_set_dn(const uint8_t* wbase, size_t stride, const size_t* tr, const int* hbounds, int n_exp, int total_rows, int n_embd, int n_ff, float limit, const uint16_t* h16, int ldh, float* y, int ldy, int tg, int td);
bool dequant_rows(int kind, const void* raw, size_t ld, int k, int n, uint16_t* out);
bool gemm_f16_nt(const uint16_t* W16, const uint16_t* X16, int ldx, float* Y, int ldy, int rows, int N, int K, float alpha, float beta);
bool swiglu(const float* g, const float* u, int n, float lim, float* h);
bool unpack_sel(const void* ckv, const int* sel, int sel_stride, int gen, long long p0, int t0, int R, int kcap, const int* nsel, int cap, void* out, int T, int nsel_count, size_t row_bytes);
// generated from ds4_host.cpp and ds4_finish.cpp
bool block_max(const float* score, long long stride, int T, const void* rows, int blk, float* bm, long long bm_stride, int nb_max);
bool combine(const float* ed, const float* cpu, const float* sh, const void* c, int T, int dim, float* out);
bool compress_win(const float* kv, const float* score, float* kv_state, float* score_state, int ratio, int p0, int T, int d, const float* w, float eps, float* latent);
bool confidence(const float* x, const uint16_t* E, const int* tok, const float* w, int T, int dim, int R, float* out);
bool embed(const uint16_t* rows, int T, float* h, int dim, int hc, void*);
bool embed_addr(const uint16_t* rows, int T, float* h, int dim, int hc);
bool engram_dequant(const uint8_t* rows, const uint8_t* scales, int n, int hd, float* out, int fmt);
bool engram_dequant_tok(const uint8_t* src, size_t per, int cols, int T, int hd, float* out, int fmt);
bool engram_gate(float* h, const float* kv, const float* w, int hc, int dim, int T, float eps);
bool ext_to_ring(const void* ext, void* ring, long long p0, int n, int T, int d, size_t row_bytes, void* backup);
bool finish(float* x, int n_vec, int len, long long stride, const FinishIn& f);
bool gather_rows(const float* x, const void* g, int n, int cols, float* out);
bool gemv_f16_rows(const uint16_t* W, const float* x, size_t ldx, int n, int k, int T, float alpha, float* y, size_t ldy);
bool gemv_f32(const float* W, const float* x, int n, int k, int T, float* y, int ldy);
bool hc_mean(const float* h, int T, int hc, int dim, float* out, long long out_stride);
bool hc_mix(const float* h, const float* fn, int hcdim, int n_mix, int T, float eps, float* mixes);
bool hc_mix_scale(const float* h, int hcdim, int n_mix, int T, float eps, float* mixes);
bool hc_post(const float* y, float* h, const float* post, const float* comb, int dim, int hc, int T);
bool hc_post_v(const float* y, const float* res, const float* post, const float* comb, int dim, int hc, int T, float* out);
bool hc_pre_norm(const float* h, const float* mix, const float* w, int dim, int hc, int T, float eps, float* x);
bool hc_split(const float* mixes, const float* scale, const float* base, int hc, int iters, float hc_eps, int T, float* pre, float* post, float* comb);
bool hc_mix_split(const float* h, const float* fn, int hcdim, int n_mix, int T, float eps, float* mixes, const float* scale, const float* base, int hc, int iters, float hc_eps, float* pre, float* post, float* comb);
bool index_score(const float* q, const float* wq, const void* kc, int heads, int hd, int T, int n, const int* cand, int cand_stride, int blk, const void* rows, float* out, long long out_stride, int kfmt);
bool init_pre(float* pre, int T, void*);
bool iota_rows(int* out, int T, int n, int stride);
bool iq_dn(const unsigned long long* plan_ptr, const unsigned long long* grp_ptr, const int* grp_start, const int* grp_ent, const int* n_groups, const size_t* tr, int n_embd, int n_ff, const int8_t* hq, const float* hd, float* part, int type, int groups);
bool iq_gu(const unsigned long long* plan_ptr, const unsigned long long* grp_ptr, const int* grp_start, const int* grp_ent, const int* n_groups, const size_t* tr, int n_embd, int n_ff, float limit, const int8_t* xq, const float* xd, float* h, int type, int groups);
bool iq_mv(const void* jobs, int nj);
bool markov_add(const float* logits, const uint16_t* E, const uint16_t* H, const int* tok, int V, int R);
bool moe_combine(void* acc, const void* y, const int* pos, const float* w_, int T, int k, int r0, int r1, int dim);
bool moe_sort(const int* idx, int T, int k, const int* vs, int n_vs, int* bounds, int* row_tok, int* pos);
bool moe_win_group_cpu(const void* plan_ptr, void* grp_ptr, void* grp_start, void* grp_ent, void* n_groups, int T, int k);
bool mx_combine(const void* plan_ptr, const void* plan_w, const void* part, const void* cpu_part, size_t cpu_stride, const int* cpu_flag, const void* sh_out, void* out, size_t out_stride, int T, int k, int n_embd);
bool mx_dn(const unsigned long long* plan_ptr, const unsigned long long* grp_ptr, const int* grp_start, const int* grp_ent, const int* n_groups, const size_t* tr, int n_embd, int n_ff, const int8_t* hq, const float* hd, float* part, int groups);
bool mx_gu(const unsigned long long* plan_ptr, const unsigned long long* grp_ptr, const int* grp_start, const int* grp_ent, const int* n_groups, const size_t* tr, int n_embd, int n_ff, float limit, const int8_t* xq, const float* xd, float* h, int groups);
bool mx_mv(const void* jobs, int nj, int max_n, size_t job_bytes, int fmt);
bool q8_quant_rows(const float* x, size_t ldx, int rows, int k, int8_t* q, float* d);
bool ring_to_ext(const void* ring, void* ext, long long p0, int n, int d, size_t row_bytes);
bool route(const float* logits, const float* bias, int n, int k, int T, float route_scale, int* idx, float* w, const float* bias_vl, const void* vl);
bool route_publish(const float* logits, const float* bias, int n, int k, int T, float route_scale, const void* vram, int* idx, float* w, void* plan_ptr, const float* bias_vl, unsigned vl);
bool sample_greedy(const float* logits, int n_tokens, int n_vocab, const int* history, int history_len, int penalty_last_n, float penalty_repeat, float penalty_freq, float penalty_present, const float* logit_bias, int* out);
bool sgemm_tn(const float* A, const float* B, float* C, int m, int nt, int k, int lda, int ldb, int ldc);
bool sparse_attn(const float* q, const uint16_t* win, const void* ckv, const int* sel, int sel_stride, const void* rows, const float* sink, int heads, int d, int T, float scale, float* out, int ckv_fmt);
bool swiglu(const float* g, const float* u, int n, float lim, float* h);
bool topk(const float* vals, long long stride, int T, const void* rows, const int* cand, int cand_stride, int blk, int* out, int out_stride);
bool unpack_sel(const void* ckv, const int* sel, int sel_stride, int gen, long long p0, int t0, int R, int kcap, const int* nsel, int cap, void* out, int T, int nsel_count, size_t row_bytes);
} // namespace strata::dsv41::metal_host
+38
View File
@@ -0,0 +1,38 @@
// mma.h - the wmma surface on Metal: the names parse, and using one stops the run.
//
// The DeepSeek token path does not use tensor cores. The prompt path has an MMA variant written for CUDA/HIP warp matrices;
// Metal's equivalent is the simdgroup matrix API, which is a different shape, so that variant is a later piece of the port.
// These aliases exist so the shared sources compile (which is how the rest of the API surface is enumerated), and every
// operation aborts with a message naming this file - an accidental call cannot return plausible garbage.
#pragma once
#include <cstdio>
#include <cstdlib>
namespace nvcuda {
namespace wmma {
// CUDA 12 spells these as tags: fragment<accumulator, 16, 16, 16, float>
struct matrix_a {};
struct matrix_b {};
struct accumulator {};
struct row_major {};
struct col_major {};
enum layout_t { mem_row_major, mem_col_major };
template <typename Use, int M, int N, int K, typename T, typename Layout = void> struct fragment {
T x[M * N];
};
[[noreturn]] inline void strata_metal_wmma_missing(const char* what) {
std::fprintf(stderr, "strata metal: wmma::%s has no Metal implementation (simdgroup matrices are the equivalent)\n", what);
std::abort();
}
template <typename... A> inline void fill_fragment(A&&...) { strata_metal_wmma_missing("fill_fragment"); }
template <typename... A> inline void load_matrix_sync(A&&...) { strata_metal_wmma_missing("load_matrix_sync"); }
template <typename... A> inline void mma_sync(A&&...) { strata_metal_wmma_missing("mma_sync"); } // variadic: the engine passes four fragments of different types
template <typename... A> inline void store_matrix_sync(A&&...) { strata_metal_wmma_missing("store_matrix_sync"); }
} // namespace wmma
} // namespace nvcuda
+25
View File
@@ -0,0 +1,25 @@
// metal/include/metal_compat/vector_types.h - the CUDA vector types the dsv41 sources name, on Metal.
//
// The compat directory had none, so the first half-precision header to use float2 failed. Only the shapes the engine
// touches are here (float2/float4/int2/uint2 and their make_ helpers); the Metal runtime's own float4 is not involved.
#pragma once
struct float2 { float x, y; };
struct float4 { float x, y, z, w; };
struct int2 { int x, y; };
struct uint2 { unsigned x, y; };
static inline float2 make_float2(float x, float y) { return float2{x, y}; }
static inline float4 make_float4(float x, float y, float z, float w) { return float4{x, y, z, w}; }
static inline int2 make_int2(int x, int y) { return int2{x, y}; }
static inline uint2 make_uint2(unsigned x, unsigned y) { return uint2{x, y}; }
struct int4 { int x, y, z, w; };
struct uint4 { unsigned x, y, z, w; };
struct char4 { signed char x, y, z, w; };
struct uchar4 { unsigned char x, y, z, w; };
struct short2 { short x, y; };
struct int3 { int x, y, z; };
static inline int4 make_int4(int x, int y, int z, int w) { return int4{x, y, z, w}; }
static inline uint4 make_uint4(unsigned x, unsigned y, unsigned z, unsigned w) { return uint4{x, y, z, w}; }
+1
View File
@@ -38,6 +38,7 @@ struct DeviceProp { // the fields the host code reads
char gcnArchName[256] = {};
int major = 0, minor = 0, warpSize = 32, multiProcessorCount = 0;
size_t totalGlobalMem = 0;
int integrated = 0; // the DeepSeek engine reads it (a GPU on the PC's RAM: Strix Halo); 0 here: its rules for that part were not tuned for Apple GPUs
};
inline constexpr unsigned kHostAllocDefault = 0;
+45 -8
View File
@@ -635,12 +635,13 @@ template <> struct W8<T_IQ2_XXS> {
device const uchar* bl = row + (k >> 8) * 66;
const int ib = (k & 255) >> 5, l = (k & 31) >> 3, hh = (k & 7) >> 2;
const uint a0 = rd32(bl + 2 + 8 * ib), a1 = rd32(bl + 2 + 8 * ib + 4);
threadgroup const uchar* ks = (threadgroup const uchar*)(tab + 512);
const float sc = rdh(bl) * (0.5f + float(a1 >> 28)) * 0.25f;
const uint i0 = (a0 >> (8 * l)) & 255, i1 = (a0 >> (8 * (l + 2))) & 255;
const uint s0 = ks[(a1 >> (7 * l)) & 127], s1 = ks[(a1 >> (7 * (l + 2))) & 127];
a = w4_signed(tab[2 * i0 + hh], hh ? s0 >> 4 : s0, sc);
b = w4_signed(tab[2 * i1 + hh], hh ? s1 >> 4 : s1, sc);
// through the accessors, so QUANT_CTAB (the constant grids, no threadgroup copy) gives the same values: the
// direct tab[] reads below skipped the ctab branch and read an unfilled table
const uint s0 = ksg(tab, 512, (a1 >> (7 * l)) & 127), s1 = ksg(tab, 512, (a1 >> (7 * (l + 2))) & 127);
a = w4_signed(g_iq2xxs(tab, i0, hh), hh ? s0 >> 4 : s0, sc);
b = w4_signed(g_iq2xxs(tab, i1, hh), hh ? s1 >> 4 : s1, sc);
}
};
template <> struct W8<T_IQ2_XS> {
@@ -649,14 +650,14 @@ template <> struct W8<T_IQ2_XS> {
const int ib = (k & 255) >> 5, l = (k & 31) >> 3, hh = (k & 7) >> 2;
device const ushort* q = (device const ushort*)(bl + 2) + 4 * ib;
const uint v0 = q[l], v1 = q[l + 2];
threadgroup const uchar* ks = (threadgroup const uchar*)(tab + 1024);
const uint scb = bl[66 + ib];
const float d = rdh(bl);
const float sc0 = d * (0.5f + float((scb >> (4 * (l >> 1))) & 15)) * 0.25f;
const float sc1 = d * (0.5f + float((scb >> (4 * ((l + 2) >> 1))) & 15)) * 0.25f;
const uint s0 = ks[v0 >> 9], s1 = ks[v1 >> 9];
a = w4_signed(tab[2 * (v0 & 511) + hh], hh ? s0 >> 4 : s0, sc0);
b = w4_signed(tab[2 * (v1 & 511) + hh], hh ? s1 >> 4 : s1, sc1);
// through the accessors: the direct tab[]/ks[] reads ignored QUANT_CTAB, whose tables are then never filled
const uint s0 = ksg(tab, 1024, v0 >> 9), s1 = ksg(tab, 1024, v1 >> 9);
a = w4_signed(g_iq2xs(tab, v0 & 511, hh), hh ? s0 >> 4 : s0, sc0);
b = w4_signed(g_iq2xs(tab, v1 & 511, hh), hh ? s1 >> 4 : s1, sc1);
}
};
template <> struct W8<T_IQ2_S> {
@@ -675,6 +676,42 @@ template <> struct W8<T_IQ2_S> {
b = w4_signed(g_iq2s(tab, i1, hh), hh ? s1 >> 4 : s1, sc1);
}
};
// IQ1_M (the 2.00 bpw pack's 1.75-bit type), ggml's dequantize_row_iq1_m: the block's F16 scale sits in the top
// nibbles of its four u16 scales; a 32-value sub-block has two 3-bit scales (groups 0-1 and 2-3, eight values each),
// and each eight-value group carries a +-0.125 delta picked by a bit of its qh byte. The tile's two halves are the
// four values at k and the four at k + 16, each decoded on its own (k + 16 crosses into the next sub-block at l >= 2).
template <typename P>
static inline half4 iq1m_w4(P row, int k) {
const P bl = row + (k >> 8) * 56;
const int ib = (k & 255) >> 5, g = (k & 31) >> 3, hh = (k & 7) >> 2;
const uint s0 = uint(bl[48]) | (uint(bl[49]) << 8), s1 = uint(bl[50]) | (uint(bl[51]) << 8);
const uint s2 = uint(bl[52]) | (uint(bl[53]) << 8), s3 = uint(bl[54]) | (uint(bl[55]) << 8);
const float d = float(as_type<half>(ushort((s0 >> 12) | ((s1 >> 8) & 0x00f0u) | ((s2 >> 4) & 0x0f00u) | (s3 & 0xf000u))));
const uint sc = uint(((const device ushort*) (bl + 48))[ib >> 1]);
const float dl = d * float(2u * ((sc >> (6u * uint(ib & 1) + (g < 2 ? 0u : 3u))) & 7u) + 1u);
const uint qh = (g < 2) ? uint(bl[32 + 2 * ib]) : uint(bl[33 + 2 * ib]);
const uint idx = uint(bl[4 * ib + g]) | (((g & 1) != 0) ? ((qh << 4u) & 0x700u) : ((qh << 8u) & 0x700u));
const bool neg = ((g & 1) != 0) ? ((qh & 0x80u) != 0u) : ((qh & 0x08u) != 0u);
const ulong gr = iq1s_grid[idx];
const float delta = neg ? -0.125f : 0.125f;
float v[4];
for (int j = 0; j < 4; ++j) {
const uint by = uint((gr >> (8u * uint(4 * hh + j))) & 0xffu);
const int c = (by & 0x80u) != 0u ? int(by) - 256 : int(by);
v[j] = dl * (float(c) + delta);
}
return half4(v[0], v[1], v[2], v[3]);
}
template <> struct W8<T_IQ1_M> {
template <typename P>
static inline void run(P row, int k, threadgroup const uint* tab, thread half4& a, thread half4& b) {
(void) tab;
a = iq1m_w4(row, k);
b = iq1m_w4(row, k + 16);
}
};
template <> struct W8<T_IQ3_S> {
template <typename P>
static inline void run(P row, int k, threadgroup const uint* tab, thread half4& a, thread half4& b) {
+4
View File
@@ -25,6 +25,7 @@
#define T_IQ3_XXS 18
#define T_IQ4_NL 20
#define T_IQ3_S 21
#define T_IQ1_M 29 // the 2.00 bpw pack's 1.75-bit type
#define T_IQ2_S 22
#define T_IQ4_XS 23
#define T_Q2_0 42
@@ -39,6 +40,7 @@ template <> struct fmt<T_Q2_0> { enum { QK = 64, BB = 18, UNIT = 16, TAB =
template <> struct fmt<T_Q2_0R> { enum { QK = 64, BB = 18, UNIT = 16, TAB = 1 }; };
template <> struct fmt<T_IQ3_XXS> { enum { QK = 256, BB = 98, UNIT = 16, TAB = 256 + 32 }; };
template <> struct fmt<T_IQ2_XXS> { enum { QK = 256, BB = 66, UNIT = 16, TAB = 512 + 32 }; };
template <> struct fmt<T_IQ1_M> { enum { QK = 256, BB = 56, UNIT = 16, TAB = 1 }; };
template <> struct fmt<T_IQ2_XS> { enum { QK = 256, BB = 74, UNIT = 16, TAB = 1024 + 32 }; };
template <> struct fmt<T_IQ2_S> { enum { QK = 256, BB = 82, UNIT = 16, TAB = 2048 + 32 }; };
template <> struct fmt<T_IQ3_S> { enum { QK = 256, BB = 110, UNIT = 16, TAB = 512 + 32 }; };
@@ -389,6 +391,8 @@ template <> inline void load_tab<T_IQ2_XS>(threadgroup uint* tab, ushort tid, us
for (int i = tid; i < 512; i += nth) { tab[2 * i] = uint(iq2xs_grid[i]); tab[2 * i + 1] = uint(iq2xs_grid[i] >> 32); }
load_signs(tab, 1024, tid, nth);
}
// IQ1_M: no threadgroup table - the tile reads iq1s_grid from the constants (the w4 helper in moe_mm.metal)
template <> inline void load_tab<T_IQ1_M>(threadgroup uint*, ushort, ushort) {}
template <> inline void load_tab<T_IQ2_S>(threadgroup uint* tab, ushort tid, ushort nth) {
if (ctab) return;
for (int i = tid; i < 1024; i += nth) { tab[2 * i] = uint(iq2s_grid[i]); tab[2 * i + 1] = uint(iq2s_grid[i] >> 32); }
+80
View File
@@ -0,0 +1,80 @@
// metal/src/engine/ds4_ctx.cpp - the one-Context allocator the dsv41 engine can opt into.
//
// Why: a Metal kernel must be handed the gpuAddress of a buffer that the SAME mtl::Context encodes the dispatch for (its
// residency set). strata::gpu, which metal_compat's cudaMalloc maps to, is a CUDA-shaped facade with its own device state, so a
// dsv41 launch would read a foreign residency set. This file gives metal_compat an alternative: allocate inside one Context and
// remember gpuAddress -> Buffer, so wrap()/Arg::B can adopt the memory without a copy.
//
// Off by default: the engine's default path stays exactly as it was. Set STRATA_DS4_METAL_CTX=1 to route allocations here -
// which becomes testable the moment a pack exists (the engine allocates only after it has read ds4.txt).
#include "mtl_rt.h"
#include <cstdio>
#include <cstdlib>
#include <mutex>
#include <unordered_map>
namespace strata::dsv41::metal_ctx {
namespace {
struct Entry {
mtl::Buffer buf;
size_t bytes = 0;
};
std::mutex g_mu;
std::unordered_map<void*, Entry>* g_map = nullptr; // gpuAddress -> buffer
mtl::Context* g_cx = nullptr;
bool enabled() {
static const bool on = std::getenv("STRATA_DS4_METAL_CTX") != nullptr;
return on;
}
mtl::Context& cx() {
if (!g_cx) g_cx = new mtl::Context();
return *g_cx;
}
} // namespace
bool active() { return enabled(); }
void* alloc(size_t bytes) {
if (!enabled()) return nullptr;
const size_t page = 16384;
const size_t rounded = (bytes + page - 1) / page * page;
mtl::Buffer b = cx().alloc(rounded);
if (!b.handle || !b.ptr) {
std::fprintf(stderr, "dsv41 metal: Context::alloc(%zu) failed\n", bytes);
return nullptr;
}
std::lock_guard<std::mutex> lk(g_mu);
if (!g_map) g_map = new std::unordered_map<void*, Entry>();
(*g_map)[b.ptr] = Entry{b, rounded};
return b.ptr; // the CPU address doubles as the handle's key; the device pointer is b.handle's gpuAddress
}
void* host_view_of(void* p) {
std::lock_guard<std::mutex> lk(g_mu);
if (!g_map) return nullptr;
auto it = g_map->find(p);
return it == g_map->end() ? nullptr : it->second.buf.ptr;
}
const mtl::Buffer* buffer_of(void* p) {
std::lock_guard<std::mutex> lk(g_mu);
if (!g_map) return nullptr;
auto it = g_map->find(p);
return it == g_map->end() ? nullptr : &it->second.buf;
}
void release(void* p) {
std::lock_guard<std::mutex> lk(g_mu);
if (!g_map) return;
auto it = g_map->find(p);
if (it == g_map->end()) return;
cx().free(it->second.buf);
g_map->erase(it);
}
} // namespace strata::dsv41::metal_ctx
File diff suppressed because it is too large. Load diff
+254
View File
@@ -0,0 +1,254 @@
// metal/src/engine/ds4_host.cpp - host side of the ported dsv41 kernels: compile the MSL once, wrap the engine's own buffers,
// dispatch. First entry point: the Q8 activation quantiser (ds4_gguf.cu:201).
//
// The two runtimes meet on pointers: strata::gpu owns the engine's device memory (a device pointer is its MTLBuffer's
// gpuAddress) and exposes host_view(device_pointer); mtl::Context::wrap() adopts that address without copying and a
// Dispatch names it through Arg::B(). See RIGS/m1002.md for the derivation.
#include "mtl_rt.h"
#include <strata/gpu/runtime.hpp>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <strata/gpu/runtime.hpp>
#include <vector>
namespace strata::dsv41::metal_host {
mtl::Context& finish_cx(); // ds4_finish.cpp's Context: every kernel that orders with the chunk path's dispatches uses it
int shared_lib(); // from ds4_finish.cpp: the library the shared Context compiled
namespace {
mtl::Context* g_cx = nullptr;
int g_lib = -1;
bool g_tried = false;
bool ensure(mtl::Context& cx) {
if (g_cx == &cx && g_lib >= 0) return true;
if (g_tried && g_lib < 0) return false;
g_tried = true;
// the kernel lives in its own file and is self-contained (metal_stdlib + ggml_grids.h)
g_lib = cx.compile({"../src/engine/msl/ds4_quant.metal"}, "", false);
if (g_lib < 0) {
std::fprintf(stderr, "dsv41 metal: compiling ds4_quant.metal failed\n");
return false;
}
g_cx = &cx;
return true;
}
} // namespace
// q8(x, ldx, n) -> q (int8, n bytes) and d (half, n/32); ldx is the stride in values between blocks of 32
bool q8_quant(mtl::Context& cx, const float* x, int ldx, int n, void* q, void* d) {
if (!ensure(cx)) return false;
const size_t xb = (size_t) ldx * (size_t) (n / 32) * sizeof(float), qb = (size_t) n, db = (size_t) (n / 32) * 2;
mtl::Buffer bx = cx.wrap(strata::gpu::host_view(x), xb);
mtl::Buffer bq = cx.wrap(q, qb);
mtl::Buffer bd = cx.wrap(d, db);
mtl::Pipeline p;
try {
p = cx.pipeline(g_lib, "ds4_q8_quant");
} catch (...) {
return false;
}
mtl::Dispatch ds;
ds.p = &p;
ds.args = {mtl::Arg::B(bx), mtl::Arg::V((unsigned) ldx), mtl::Arg::B(bq), mtl::Arg::B(bd)};
ds.grid[0] = (uint32_t) (n / 32);
ds.grid[1] = ds.grid[2] = 1;
ds.tg[0] = 32;
ds.tg[1] = ds.tg[2] = 1;
mtl::require_fit(p, 32, "ds4_q8_quant");
cx.run({ds});
return true;
}
} // namespace strata::dsv41::metal_host
namespace strata::dsv41::metal_host {
namespace {
mtl::Context* g_init_cx = nullptr;
int g_lib_init = -1;
mtl::Context& shared_cx() {
if (!g_init_cx) {
g_init_cx = new mtl::Context();
g_lib_init = g_init_cx->compile({"../src/engine/msl/ds4_init.metal"}, "", false);
if (g_lib_init < 0) std::fprintf(stderr, "dsv41 metal: compiling ds4_init.metal failed\n");
}
return *g_init_cx;
}
} // namespace
// ds4_kernels.cu:1477 - pre[i] = (i % 8) == 0 ? 1 : 0 for i < T*8
bool init_pre(float* pre, int T, void*) {
// cpu path (T*8 floats of a fixed pattern): the GPU route had to wrap a whole page to reach 32 bytes, and that aliasing
// buffer overlapped the pages holding h
void* host = strata::gpu::host_view(pre);
if (!host) { std::fprintf(stderr, "dsv41 metal: init_pre has no host view\n"); return false; }
float* p = (float*) host;
for (int t = 0; t < T; ++t)
for (int i = 0; i < 8; ++i) p[t * 8 + i] = (i == 0) ? 1.0f : 0.0f;
return true;
}
// ds4_kernels.cu:130 - the BF16 embedding gather, hc copies a token
bool embed(const uint16_t* rows, int T, float* h, int dim, int hc, void*) {
strata::dsv41::metal_host::shared_cx();
if (g_lib_init < 0) { std::fprintf(stderr, "dsv41 metal: embed: no library\n"); return false; }
// host_view() returns a different address from the device pointer on this runtime, so wrapping it wrote into a mapping the
// engine never reads. On Apple silicon a device pointer is a valid CPU address for shared storage: wrap that instead.
// DS4_HOSTVIEW=1 restores the old behaviour.
const bool use_hv = std::getenv("DS4_HOSTVIEW") != nullptr;
void* hrows = use_hv ? strata::gpu::host_view((void*) rows) : (void*) rows;
void* hh = use_hv ? strata::gpu::host_view(h) : (void*) h;
if (!hrows || !hh) { std::fprintf(stderr, "dsv41 metal: embed has no host view\n"); return false; }
if (std::getenv("DS4_DBG_HV"))
std::fprintf(stderr, "DBG hostview: rows %p -> %p h %p -> %p\n",
(void*) rows, hrows, (void*) h, hh);
const size_t page = 16384;
auto wrap = [&](void* host, size_t bytes) {
return strata::dsv41::metal_host::shared_cx().wrap((void*) (((uintptr_t) host) & ~(uintptr_t) (page - 1)), ((bytes + page - 1) / page) * page);
};
mtl::Buffer br = wrap(hrows, (size_t) T * dim * 2);
mtl::Buffer bh = wrap(hh, (size_t) T * hc * dim * 4);
if (!br.handle || !bh.handle) { std::fprintf(stderr, "dsv41 metal: embed wrap failed\n"); return false; }
const size_t dro = (size_t) (((uintptr_t) hrows) - (((uintptr_t) hrows) & ~(uintptr_t) (page - 1)));
const size_t dho = (size_t) (((uintptr_t) hh) - (((uintptr_t) hh) & ~(uintptr_t) (page - 1)));
mtl::Pipeline pl;
try { pl = strata::dsv41::metal_host::shared_cx().pipeline(g_lib_init, "ds4_embed"); } catch (...) { return false; }
mtl::Dispatch d;
d.p = &pl;
d.args = {mtl::Arg::B(br, dro), mtl::Arg::B(bh, dho), mtl::Arg::V((unsigned) dim), mtl::Arg::V((unsigned) hc)};
d.grid[0] = (uint32_t) ((dim + 255) / 256);
d.grid[1] = (uint32_t) T;
d.grid[2] = 1;
d.tg[0] = 256; d.tg[1] = d.tg[2] = 1;
strata::dsv41::metal_host::shared_cx().run({d});
if (std::getenv("DS4_DBG_EMBWRITE")) {
strata::dsv41::metal_host::shared_cx().flush_runs();
const float* f0 = (const float*) hh;
const uint16_t* r16 = (const uint16_t*) hrows;
std::fprintf(stderr, "DBG embwrite: h=%p rows=%p h_off=%zu rows_off=%zu\n", hh, hrows, dho, dro);
std::fprintf(stderr, "DBG embwrite: rows[0..3]=%04x %04x %04x %04x h[0..3]=%g %g %g %g\n",
(unsigned) r16[0], (unsigned) r16[1], (unsigned) r16[2], (unsigned) r16[3],
f0[0], f0[1], f0[2], f0[3]);
}
return true;
}
// ds4_model.cu:2737 - C[t][m] = sum_k A[m][k] * B[t][k], F32 operands, alpha = 1, beta = 0
bool sgemm_tn(const float* A, const float* B, float* C, int m, int nt, int k, int lda, int ldb, int ldc) {
finish_cx();
const int lib = shared_lib();
if (lib < 0) return false;
void* ha = strata::gpu::host_view((void*) A);
void* hb = strata::gpu::host_view((void*) B);
void* hc = strata::gpu::host_view((void*) C);
if (!ha || !hb || !hc) { std::fprintf(stderr, "dsv41 metal: sgemm_tn has no host view\n"); return false; }
const size_t page = 16384;
auto wrap = [&](void* host, size_t bytes) {
return finish_cx().wrap((void*) (((uintptr_t) host) & ~(uintptr_t) (page - 1)), ((bytes + page - 1) / page) * page);
};
auto off = [&](void* host) { return (size_t) ((uintptr_t) host - (((uintptr_t) host) & ~(uintptr_t) (page - 1))); };
mtl::Buffer ba = wrap(ha, (size_t) m * lda * 4);
mtl::Buffer bb = wrap(hb, (size_t) nt * ldb * 4);
mtl::Buffer bc = wrap(hc, (size_t) nt * ldc * 4);
if (!ba.handle || !bb.handle || !bc.handle) return false;
mtl::Pipeline pl;
try { pl = finish_cx().pipeline(lib, "ds4_sgemm_tn"); } catch (...) { return false; }
mtl::Dispatch d;
d.p = &pl;
d.args = {mtl::Arg::B(ba, off(ha)), mtl::Arg::B(bb, off(hb)), mtl::Arg::B(bc, off(hc)),
mtl::Arg::V((unsigned) m), mtl::Arg::V((unsigned) nt), mtl::Arg::V((unsigned) k),
mtl::Arg::V((unsigned) lda), mtl::Arg::V((unsigned) ldb), mtl::Arg::V((unsigned) ldc)};
const uint64_t total = (uint64_t) m * (uint64_t) nt;
d.grid[0] = (uint32_t) ((total + 127) / 128);
d.grid[1] = d.grid[2] = 1;
d.tg[0] = 128; d.tg[1] = d.tg[2] = 1;
finish_cx().run({d});
if (std::getenv("DS4_DBG_SG")) {
finish_cx().flush_runs();
const float* fa = (const float*) ha; const float* fb = (const float*) hb; const float* fc = (const float*) hc;
std::fprintf(stderr, "sgemm m %d nt %d k %d lda %d ldb %d ldc %d A %g %g B %g %g C %g %g %g\n", m, nt, k, lda, ldb, ldc, fa[0], fa[1], fb[0], fb[1], fc[0], fc[1], fc[2]);
}
return true;
}
// embed through raw device addresses: the struct carries the gpuAddress of rows and h, which the runtime's residency set keeps
// resident, so the kernel writes the engine's own memory instead of a CPU mirror.
struct Ds4EmbedArgsHost {
uint64_t rows;
uint64_t h;
uint32_t dim;
uint32_t hc;
};
bool embed_addr(const uint16_t* rows, int T, float* h, int dim, int hc) {
// the engine uploads emb_rows on ITS stream; this kernel runs on mtl::Context queue. Order the two before reading.
strata::gpu::stream_synchronize(nullptr);
if (std::getenv("DS4_DBG_ROWS")) { // what is the engine actually handing us as the embedding rows?
const uint16_t* rv = (const uint16_t*) strata::gpu::host_view((void*) rows);
std::fprintf(stderr, "DBG rows_in: ptr=%p view=%p first=%04x %04x %04x %04x\n",
(const void*) rows, (const void*) rv,
rv ? (unsigned) rv[0] : 0u, rv ? (unsigned) rv[1] : 0u,
rv ? (unsigned) rv[2] : 0u, rv ? (unsigned) rv[3] : 0u);
}
strata::dsv41::metal_host::shared_cx();
const int lib = shared_lib();
if (lib < 0) return false;
Ds4EmbedArgsHost a{};
a.rows = (uint64_t) (uintptr_t) rows;
a.h = (uint64_t) (uintptr_t) h;
a.dim = (uint32_t) dim;
a.hc = (uint32_t) hc;
mtl::Pipeline pl;
try { pl = strata::dsv41::metal_host::shared_cx().pipeline(lib, "ds4_embed_j"); } catch (...) { return false; }
mtl::Dispatch d;
d.p = &pl;
d.args = {mtl::Arg::V(a)};
d.grid[0] = (uint32_t) ((dim + 255) / 256);
d.grid[1] = (uint32_t) T;
d.grid[2] = 1;
d.tg[0] = 256; d.tg[1] = d.tg[2] = 1;
strata::dsv41::metal_host::shared_cx().run({d});
strata::dsv41::metal_host::shared_cx().flush_runs();
if (std::getenv("DS4_DBG_CMP")) {
const float* hv = (const float*) strata::gpu::host_view(h);
{
const size_t nv = (size_t) T * hc * dim;
size_t nf = 0, nz = 0;
for (size_t i = 0; i < nv; ++i) { const float v = hv[i]; if (!(v == v)) ++nf; else if (v == 0.0f) ++nz; }
std::fprintf(stderr, "DBG cmp: after embed n=%.0f nonfinite=%zu zeros=%zu\n", (double) nv, nf, nz);
}
std::fprintf(stderr, "DBG cmp: view %g %g %g | h=%g h+1=%g\n",
hv ? hv[0] : -1.0f, hv ? hv[1] : -1.0f, hv ? hv[2] : -1.0f,
hv ? hv[(size_t) dim * hc] : -1.0f, hv ? hv[(size_t) dim * hc + 1] : -1.0f);
}
if (std::getenv("DS4_DBG_ARGS")) {
mtl::Buffer bp = strata::dsv41::metal_host::shared_cx().wrap(
(void*) (((uintptr_t) strata::gpu::host_view(h)) & ~(uintptr_t) 16383), 16384);
mtl::Pipeline pp = strata::dsv41::metal_host::shared_cx().pipeline(lib, "ds4_args_probe");
mtl::Dispatch pd;
pd.p = &pp;
pd.args = {mtl::Arg::B(bp, ((uintptr_t) strata::gpu::host_view(h)) & 16383),
mtl::Arg::V((unsigned) dim), mtl::Arg::V((unsigned) hc)};
pd.grid[0] = 1; pd.grid[1] = pd.grid[2] = 1; pd.tg[0] = 32; pd.tg[1] = pd.tg[2] = 1;
strata::dsv41::metal_host::shared_cx().run({pd});
strata::dsv41::metal_host::shared_cx().flush_runs();
float got[3] = {0, 0, 0};
{ const float* hv2 = (const float*) strata::gpu::host_view(h); if (hv2) { got[0] = hv2[0]; got[1] = hv2[1]; } }
std::fprintf(stderr, "DBG args: kernel sees dim=%g hc=%g\n", got[0], got[1]);
}
return true;
}
} // namespace strata::dsv41::metal_host
+29
View File
@@ -0,0 +1,29 @@
// metal/src/engine/exl3_legacy_stub.cpp - v1.1.8's EXL3 decode kernels, absent on Metal.
//
// exl3_legacy.cu exists for NVIDIA GPUs before sm_80, which pick it over the newer tiles; nothing on a Mac can select it
// (use_legacy() is false without CUDA), and the 2.25bpw text model does not use EXL3 at all. Leaving the file out of the
// target would break the link for these seven entry points, so they are defined here and stop the run naming themselves -
// the same contract as the stub generator, and a loud failure rather than a silent wrong decode.
#include <cstdio>
#include <cstdlib>
#include <strata/dsv41/exl3.hpp> // MvJob, MoeArgs, WinArgs
namespace strata::dsv41k::exl3::legacy {
[[noreturn]] static void missing(const char* what) {
std::fprintf(stderr,
"strata metal: exl3::legacy::%s has no Metal implementation (v1.1.8's EXL3 kernels; the 2.25bpw text model "
"does not use EXL3)\n", what);
std::abort();
}
int launch_errors() { return 0; }
bool prepare(cudaStream_t) { missing("prepare"); }
bool several_ok() { return false; }
bool mv(const MvJob*, int, cudaStream_t) { missing("mv"); }
void moe_gate_up(const MoeArgs&, const float*, float*, cudaStream_t) { missing("moe_gate_up"); }
void moe_down(const MoeArgs&, const float*, const float*, float*, cudaStream_t) { missing("moe_down"); }
bool win_experts(const WinArgs&, cudaStream_t) { missing("win_experts"); }
} // namespace strata::dsv41k::exl3::legacy
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+138
View File
@@ -0,0 +1,138 @@
// metal/src/engine/msl/ds4_mm.metal - the DeepSeek prompt chunk's expert GEMMs: moe_mm.metal's mm_gu3 / mm_down3 (matmul2d on dequantized
// weight tiles) over the engine's own expert slots and its UNPADDED sort order. Compiled after quant/units/mma/moe_mm.metal.
// tiles[i] = (slot, first row, valid rows): a tile is TM rows of one expert; rows past `valid` (the next expert's) are computed and
// dropped (the operand rows exist: the scratch carries TM rows of slack), the swiglu is DeepSeek's clamped one.
struct DsMmArgs {
ulong gate_off;
ulong up_off;
ulong down_off;
ulong stride; // bytes between slots
uint gu_row; // bytes per gate/up row (n_embd values)
uint d_row; // bytes per down row (n_ff values)
uint n_embd;
uint n_ff;
float limit;
uint pad0;
};
// xs[r][c] = half(x[idx[r]][c]) for r < rows
kernel void ds4_mm_gather(device const float* x [[buffer(0)]], constant ulong& ldx [[buffer(1)]], device const int* idx [[buffer(2)]],
constant uint& n_embd [[buffer(3)]], device half* xs [[buffer(4)]],
uint2 tgp [[threadgroup_position_in_grid]], uint tid [[thread_index_in_threadgroup]]) {
const uint c = tgp.x * 256u + tid;
if (c >= n_embd) return;
xs[(ulong) tgp.y * n_embd + c] = half(x[(ulong) idx[tgp.y] * ldx + c]);
}
template <int F, int TM, int TN, int TK, int SG>
kernel void ds4_mm_gu(device const uchar* W [[buffer(0)]], device const half* XS [[buffer(1)]], device half* HS [[buffer(2)]],
device const int4* tiles [[buffer(3)]], constant DsMmArgs& a [[buffer(4)]], constant uint& n_tiles [[buffer(5)]],
uint3 tg [[threadgroup_position_in_grid]], ushort sg [[simdgroup_index_in_threadgroup]],
ushort lane [[thread_index_in_simdgroup]], ushort tid [[thread_index_in_threadgroup]]) {
if (tg.y >= n_tiles) return;
threadgroup float ot[SG][TM * TN];
threadgroup uint tab[fmt<F>::TAB];
load_tab<F>(tab, tid, SG * 32);
threadgroup_barrier(mem_flags::mem_threadgroup);
const int4 tl = tiles[tg.y];
const int e = tl.x, row0 = tl.y, valid = tl.z, n0 = (int(tg.x) * SG + sg) * TN;
device const uchar* E = W + ulong(e) * a.stride;
device const uchar* g0 = E + a.gate_off + ulong(n0) * a.gu_row;
device const uchar* u0 = E + a.up_off + ulong(n0) * a.gu_row;
constexpr auto desc = matmul2d_descriptor(TM, TN, TK, false, true, false, matmul2d_descriptor::mode::multiply_accumulate);
matmul2d<desc, execution_simdgroup> op;
tensor<device half, dextents<int32_t, 2>, tensor_inline> tX((device half*)XS, dextents<int32_t, 2>(a.n_embd, 1 << 30));
auto rg = op.template get_right_input_cooperative_tensor<half, half, float>();
auto ru = op.template get_right_input_cooperative_tensor<half, half, float>();
auto tA = tX.template slice<TK, TM>(0, row0);
auto accg = op.template get_destination_cooperative_tensor<__tensor_ops_detail::__remove_addrspace_t<decltype(tA)>, __tensor_ops_detail::__remove_addrspace_t<decltype(rg)>, float>();
auto accu = op.template get_destination_cooperative_tensor<__tensor_ops_detail::__remove_addrspace_t<decltype(tA)>, __tensor_ops_detail::__remove_addrspace_t<decltype(rg)>, float>();
for (uint16_t i = 0; i < accg.get_capacity(); ++i)
if (accg.is_valid_element(i)) { accg[i] = 0.0f; accu[i] = 0.0f; }
for (int k0 = 0; k0 < int(a.n_embd); k0 += TK) {
const uint16_t cap = rg.get_capacity(), half_cap = cap / 2;
#pragma clang loop unroll(full)
for (uint16_t i = 0; i < half_cap; i += 4) {
const auto ix = rg.get_multidimensional_index(i);
const int k = k0 + ix[0], n = ix[1];
device const uchar* rgw = g0 + ulong(n) * a.gu_row;
device const uchar* ruw = u0 + ulong(n) * a.gu_row;
half4 wg, wu, wg2, wu2;
W8<F>::run(rgw, k, tab, wg, wg2);
W8<F>::run(ruw, k, tab, wu, wu2);
rg[i] = wg.x; rg[i + 1] = wg.y; rg[i + 2] = wg.z; rg[i + 3] = wg.w;
ru[i] = wu.x; ru[i + 1] = wu.y; ru[i + 2] = wu.z; ru[i + 3] = wu.w;
const uint16_t j = i + half_cap;
rg[j] = wg2.x; rg[j + 1] = wg2.y; rg[j + 2] = wg2.z; rg[j + 3] = wg2.w;
ru[j] = wu2.x; ru[j + 1] = wu2.y; ru[j + 2] = wu2.z; ru[j + 3] = wu2.w;
}
tA = tX.template slice<TK, TM>(k0, row0);
op.run(tA, rg, accg);
op.run(tA, ru, accu);
}
for (uint16_t i = 0; i < accg.get_capacity(); ++i)
if (accg.is_valid_element(i)) {
const float g = fmin(accg[i], a.limit), u = fmin(fmax(accu[i], -a.limit), a.limit);
accg[i] = g / (1.0f + exp(-g)) * u;
}
tensor<threadgroup float, dextents<int32_t, 2>, tensor_inline> tO(ot[sg], dextents<int32_t, 2>(TN, TM));
accg.store(tO);
simdgroup_barrier(mem_flags::mem_threadgroup);
for (int it = lane; it < TM * TN; it += 32) {
const int m = it / TN, f = it - m * TN;
if (m < valid) HS[ulong(row0 + m) * a.n_ff + n0 + f] = half(ot[sg][it]);
}
}
template <int F, int TM, int TN, int TK, int SG>
kernel void ds4_mm_dn(device const uchar* W [[buffer(0)]], device half* YEH [[buffer(1)]], device const half* HS [[buffer(2)]],
device const int4* tiles [[buffer(3)]], constant DsMmArgs& a [[buffer(4)]], constant uint& n_tiles [[buffer(5)]],
uint3 tg [[threadgroup_position_in_grid]], ushort sg [[simdgroup_index_in_threadgroup]],
ushort lane [[thread_index_in_simdgroup]], ushort tid [[thread_index_in_threadgroup]]) {
if (tg.y >= n_tiles || a.pad0 == 3u) return;
threadgroup float ot[SG][TM * TN];
threadgroup uint tab[fmt<F>::TAB];
load_tab<F>(tab, tid, SG * 32);
threadgroup_barrier(mem_flags::mem_threadgroup);
const int4 tl = tiles[tg.y];
const int e = tl.x, row0 = tl.y, valid = tl.z, n0 = (int(tg.x) * SG + sg) * TN;
device const uchar* w0 = W + ulong(e) * a.stride + a.down_off + ulong(n0) * a.d_row;
constexpr auto desc = matmul2d_descriptor(TM, TN, TK, false, true, false, matmul2d_descriptor::mode::multiply_accumulate);
matmul2d<desc, execution_simdgroup> op;
tensor<device half, dextents<int32_t, 2>, tensor_inline> tH((device half*)HS, dextents<int32_t, 2>(a.n_ff, 1 << 30));
auto rw = op.template get_right_input_cooperative_tensor<half, half, float>();
auto tA = tH.template slice<TK, TM>(0, row0);
auto acc = op.template get_destination_cooperative_tensor<__tensor_ops_detail::__remove_addrspace_t<decltype(tA)>, __tensor_ops_detail::__remove_addrspace_t<decltype(rw)>, float>();
for (uint16_t i = 0; i < acc.get_capacity(); ++i)
if (acc.is_valid_element(i)) acc[i] = 0.0f;
for (int k0 = 0; k0 < int(a.n_ff); k0 += TK) {
const uint16_t half_cap = rw.get_capacity() / 2;
#pragma clang loop unroll(full)
for (uint16_t i = 0; i < half_cap; i += 4) {
const auto ix = rw.get_multidimensional_index(i);
half4 w, w2;
if (a.pad0 == 1u) { w = half4(0); w2 = half4(0); } else if (a.pad0 == 2u) { w = half4(1); w2 = half4(1); W8<F>::run(w0, 0, tab, w, w2); w = half4(0); } else
W8<F>::run(w0 + ulong(ix[1]) * a.d_row, k0 + ix[0], tab, w, w2);
rw[i] = w.x; rw[i + 1] = w.y; rw[i + 2] = w.z; rw[i + 3] = w.w;
const uint16_t j = i + half_cap;
rw[j] = w2.x; rw[j + 1] = w2.y; rw[j + 2] = w2.z; rw[j + 3] = w2.w;
}
tA = tH.template slice<TK, TM>(k0, row0);
op.run(tA, rw, acc);
}
tensor<threadgroup float, dextents<int32_t, 2>, tensor_inline> tO(ot[sg], dextents<int32_t, 2>(TN, TM));
acc.store(tO);
simdgroup_barrier(mem_flags::mem_threadgroup);
for (int it = lane; it < TM * TN; it += 32) {
const int m = it / TN, f = it - m * TN;
if (m < valid) ((device float*) YEH)[ulong(row0 + m) * a.n_embd + n0 + f] = ot[sg][it];
}
}
#define DS4_MM_INST(F, T) template [[host_name("ds4_mm_gu_" #T)]] kernel decltype(ds4_mm_gu<F, 32, 32, 32, 4>) ds4_mm_gu<F, 32, 32, 32, 4>; template [[host_name("ds4_mm_dn_" #T)]] kernel decltype(ds4_mm_dn<F, 32, 32, 32, 4>) ds4_mm_dn<F, 32, 32, 32, 4>;
DS4_MM_INST(T_IQ1_M, 29)
DS4_MM_INST(T_IQ2_XXS, 16)
DS4_MM_INST(T_IQ2_XS, 17)
DS4_MM_INST(T_IQ2_S, 22)
DS4_MM_INST(T_IQ3_XXS, 18)
+32
View File
@@ -0,0 +1,32 @@
// metal/src/engine/msl/ds4_quant.metal - the DeepSeek engine's activation quantiser, the first dsv41 kernel ported.
//
// CUDA's q8 path (ds4_gguf.cu:201, q8_quant_kernel): per 32 values d = max|x| / 127 as an f16, then q = round(x / d) clamped to
// [-128, 127] as int8. Verified bit-exact against the same arithmetic on the CPU: 4096 blocks, 131,072 values, 0 differences,
// worst scale error 4.6e-04 (half rounding) - metal/tests/dsv41/q8_quant_parity.mm.
//
// The block comes from the flat thread id (thread_position_in_grid / 32), not threadgroup_position_in_grid: the latter arrived
// as 0 in every group on this Mac's driver and made every block recompute block 0.
#include <metal_stdlib>
using namespace metal;
// x: n values; ldx: the stride in values between consecutive blocks of 32 (32 for a contiguous row)
kernel void ds4_q8_quant(device const float* x [[buffer(0)]], constant uint& ldx [[buffer(1)]],
device char* q [[buffer(2)]], device half* d [[buffer(3)]],
uint gid [[thread_position_in_grid]], uint lane [[thread_index_in_threadgroup]]) {
const uint b = gid / 32u;
const int base = int(b) * int(ldx);
const float v = x[base + int(lane)];
float amax = fabs(v);
amax = max(amax, simd_shuffle_xor(amax, 16));
amax = max(amax, simd_shuffle_xor(amax, 8));
amax = max(amax, simd_shuffle_xor(amax, 4));
amax = max(amax, simd_shuffle_xor(amax, 2));
amax = max(amax, simd_shuffle_xor(amax, 1));
const float scale = amax / 127.0f;
const float inv = scale > 0.0f ? 1.0f / scale : 0.0f;
int iq = int(rint(v * inv));
iq = clamp(iq, -128, 127);
q[int(b) * 32 + int(lane)] = char(iq);
if (lane == 0) d[int(b)] = half(scale);
}
+21
View File
@@ -0,0 +1,21 @@
// metal/src/engine/msl/ggml_grids.h - ggml's i-quant grid tables, for the MSL expert kernels.
//
// Generated from third_party/ggml/ggml-common.h (pinned 3cf03257f): the CUDA and Metal kernels must use bit-identical tables,
// so they are copied from the same file rather than retyped. MSL cannot include a C header, so each array becomes a
// `constant` in the address space the kernels need - same values, same order.
#pragma once
constant uchar kmask_iq2xs[8] = {1, 2, 4, 8, 16, 32, 64, 128};
constant uchar ksigns_iq2xs[128] = {0, 129, 130, 3, 132, 5, 6, 135, 136, 9, 10, 139, 12, 141, 142, 15, 144, 17, 18, 147, 20, 149, 150, 23, 24, 153, 154, 27, 156, 29, 30, 159, 160, 33, 34, 163, 36, 165, 166, 39, 40, 169, 170, 43, 172, 45, 46, 175, 48, 177, 178, 51, 180, 53, 54, 183, 184, 57, 58, 187, 60, 189, 190, 63, 192, 65, 66, 195, 68, 197, 198, 71, 72, 201, 202, 75, 204, 77, 78, 207, 80, 209, 210, 83, 212, 85, 86, 215, 216, 89, 90, 219, 92, 221, 222, 95, 96, 225, 226, 99, 228, 101, 102, 231, 232, 105, 106, 235, 108, 237, 238, 111, 240, 113, 114, 243, 116, 245, 246, 119, 120, 249, 250, 123, 252, 125, 126, 255,};
constant ulong iq2xxs_grid[256] = {0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b2b08, 0x08080808082b2b2b, 0x0808080819080819, 0x0808080819081908, 0x0808080819190808, 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x080808082b080808, 0x080808082b08082b, 0x080808082b082b2b, 0x080808082b2b082b, 0x0808081908080819, 0x0808081908081908, 0x0808081908190808, 0x0808081908191919, 0x0808081919080808, 0x080808192b081908, 0x080808192b192b08, 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b082b082b, 0x0808082b2b08082b, 0x0808190808080819, 0x0808190808081908, 0x0808190808190808, 0x08081908082b0819, 0x08081908082b1908, 0x0808190819080808, 0x080819081908082b, 0x0808190819082b08, 0x08081908192b0808, 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, 0x0808191908082b08, 0x08081919082b0808, 0x080819191908192b, 0x08081919192b2b19, 0x080819192b080808, 0x080819192b190819, 0x0808192b08082b19, 0x0808192b08190808, 0x0808192b19080808, 0x0808192b2b081908, 0x0808192b2b2b1908, 0x08082b0808080808, 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808191908, 0x08082b08082b2b08, 0x08082b0819080819, 0x08082b0819081908, 0x08082b0819190808, 0x08082b081919082b, 0x08082b082b082b08, 0x08082b1908081908, 0x08082b1919080808, 0x08082b2b0808082b, 0x08082b2b08191908, 0x0819080808080819, 0x0819080808081908, 0x0819080808190808, 0x08190808082b0819, 0x0819080819080808, 0x08190808192b0808, 0x081908082b081908, 0x081908082b190808, 0x081908082b191919, 0x0819081908080808, 0x0819081908082b08, 0x08190819082b0808, 0x0819081919190808, 0x0819081919192b2b, 0x081908192b080808, 0x0819082b082b1908, 0x0819082b19081919, 0x0819190808080808, 0x0819190808082b08, 0x08191908082b0808, 0x08191908082b1919, 0x0819190819082b19, 0x081919082b080808, 0x0819191908192b08, 0x08191919192b082b, 0x0819192b08080808, 0x0819192b0819192b, 0x08192b0808080819, 0x08192b0808081908, 0x08192b0808190808, 0x08192b0819080808, 0x08192b082b080819, 0x08192b1908080808, 0x08192b1908081919, 0x08192b192b2b0808, 0x08192b2b19190819, 0x082b080808080808, 0x082b08080808082b, 0x082b080808082b2b, 0x082b080819081908, 0x082b0808192b0819, 0x082b08082b080808, 0x082b08082b08082b, 0x082b0819082b2b19, 0x082b081919082b08, 0x082b082b08080808, 0x082b082b0808082b, 0x082b190808080819, 0x082b190808081908, 0x082b190808190808, 0x082b190819080808, 0x082b19081919192b, 0x082b191908080808, 0x082b191919080819, 0x082b1919192b1908, 0x082b192b2b190808, 0x082b2b0808082b08, 0x082b2b08082b0808, 0x082b2b082b191908, 0x082b2b2b19081908, 0x1908080808080819, 0x1908080808081908, 0x1908080808190808, 0x1908080808192b08, 0x19080808082b0819, 0x19080808082b1908, 0x1908080819080808, 0x1908080819082b08, 0x190808081919192b, 0x19080808192b0808, 0x190808082b080819, 0x190808082b081908, 0x190808082b190808, 0x1908081908080808, 0x19080819082b0808, 0x19080819192b0819, 0x190808192b080808, 0x190808192b081919, 0x1908082b08080819, 0x1908082b08190808, 0x1908082b19082b08, 0x1908082b1919192b, 0x1908082b192b2b08, 0x1908190808080808, 0x1908190808082b08, 0x19081908082b0808, 0x190819082b080808, 0x190819082b192b19, 0x190819190819082b, 0x19081919082b1908, 0x1908192b08080808, 0x19082b0808080819, 0x19082b0808081908, 0x19082b0808190808, 0x19082b0819080808, 0x19082b0819081919, 0x19082b1908080808, 0x19082b1919192b08, 0x19082b19192b0819, 0x19082b192b08082b, 0x19082b2b19081919, 0x19082b2b2b190808, 0x1919080808080808, 0x1919080808082b08, 0x1919080808190819, 0x1919080808192b19, 0x19190808082b0808, 0x191908082b080808, 0x191908082b082b08, 0x1919081908081908, 0x191908191908082b, 0x191908192b2b1908, 0x1919082b2b190819, 0x191919082b190808, 0x191919082b19082b, 0x1919191908082b2b, 0x1919192b08080819, 0x1919192b19191908, 0x19192b0808080808, 0x19192b0808190819, 0x19192b0808192b19, 0x19192b08192b1908, 0x19192b1919080808, 0x19192b2b08082b08, 0x192b080808081908, 0x192b080808190808, 0x192b080819080808, 0x192b0808192b2b08, 0x192b081908080808, 0x192b081919191919, 0x192b082b08192b08, 0x192b082b192b0808, 0x192b190808080808, 0x192b190808081919, 0x192b191908190808, 0x192b19190819082b, 0x192b19192b081908, 0x192b2b081908082b, 0x2b08080808080808, 0x2b0808080808082b, 0x2b08080808082b2b, 0x2b08080819080819, 0x2b0808082b08082b, 0x2b08081908081908, 0x2b08081908192b08, 0x2b08081919080808, 0x2b08082b08190819, 0x2b08190808080819, 0x2b08190808081908, 0x2b08190808190808, 0x2b08190808191919, 0x2b08190819080808, 0x2b081908192b0808, 0x2b08191908080808, 0x2b0819191908192b, 0x2b0819192b191908, 0x2b08192b08082b19, 0x2b08192b19080808, 0x2b08192b192b0808, 0x2b082b080808082b, 0x2b082b1908081908, 0x2b082b2b08190819, 0x2b19080808081908, 0x2b19080808190808, 0x2b190808082b1908, 0x2b19080819080808, 0x2b1908082b2b0819, 0x2b1908190819192b, 0x2b1908192b080808, 0x2b19082b19081919, 0x2b19190808080808, 0x2b191908082b082b, 0x2b19190819081908, 0x2b19191919190819, 0x2b192b082b080819, 0x2b192b19082b0808, 0x2bLine truncated
constant ulong iq2xs_grid[512] = {0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x080808080819192b, 0x0808080808192b19, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b1919, 0x08080808082b2b08, 0x0808080819080819, 0x0808080819081908, 0x080808081908192b, 0x0808080819082b19, 0x0808080819190808, 0x080808081919082b, 0x0808080819191919, 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x080808082b080808, 0x080808082b08082b, 0x080808082b081919, 0x080808082b082b08, 0x080808082b190819, 0x080808082b191908, 0x080808082b192b19, 0x080808082b2b0808, 0x0808081908080819, 0x0808081908081908, 0x080808190808192b, 0x0808081908082b19, 0x0808081908190808, 0x080808190819082b, 0x0808081908191919, 0x0808081908192b08, 0x0808081908192b2b, 0x08080819082b0819, 0x08080819082b1908, 0x0808081919080808, 0x080808191908082b, 0x0808081919081919, 0x0808081919082b08, 0x0808081919190819, 0x0808081919191908, 0x08080819192b0808, 0x08080819192b2b08, 0x080808192b080819, 0x080808192b081908, 0x080808192b190808, 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b08081919, 0x0808082b08082b08, 0x0808082b08190819, 0x0808082b08191908, 0x0808082b082b0808, 0x0808082b19080819, 0x0808082b19081908, 0x0808082b19190808, 0x0808082b19191919, 0x0808082b2b080808, 0x0808082b2b082b2b, 0x0808190808080819, 0x0808190808081908, 0x080819080808192b, 0x0808190808082b19, 0x0808190808190808, 0x080819080819082b, 0x0808190808191919, 0x0808190808192b08, 0x08081908082b0819, 0x08081908082b1908, 0x0808190819080808, 0x080819081908082b, 0x0808190819081919, 0x0808190819082b08, 0x0808190819190819, 0x0808190819191908, 0x080819081919192b, 0x08081908192b0808, 0x080819082b080819, 0x080819082b081908, 0x080819082b190808, 0x0808191908080808, 0x080819190808082b, 0x0808191908081919, 0x0808191908082b08, 0x0808191908190819, 0x0808191908191908, 0x08081919082b0808, 0x0808191919080819, 0x0808191919081908, 0x0808191919190808, 0x08081919192b0819, 0x080819192b080808, 0x0808192b08080819, 0x0808192b08081908, 0x0808192b08190808, 0x0808192b082b192b, 0x0808192b19080808, 0x0808192b1908082b, 0x0808192b2b081908, 0x08082b0808080808, 0x08082b080808082b, 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808082b2b, 0x08082b0808190819, 0x08082b0808191908, 0x08082b08082b0808, 0x08082b08082b1919, 0x08082b0819080819, 0x08082b0819081908, 0x08082b0819190808, 0x08082b0819192b08, 0x08082b082b080808, 0x08082b082b2b0808, 0x08082b082b2b2b2b, 0x08082b1908080819, 0x08082b1908081908, 0x08082b1908190808, 0x08082b1919080808, 0x08082b192b080819, 0x08082b192b082b19, 0x08082b2b08080808, 0x08082b2b082b0808, 0x08082b2b082b2b08, 0x08082b2b2b19192b, 0x08082b2b2b2b0808, 0x0819080808080819, 0x0819080808081908, 0x081908080808192b, 0x0819080808082b19, 0x0819080808190808, 0x081908080819082b, 0x0819080808191919, 0x0819080808192b08, 0x08190808082b0819, 0x08190808082b1908, 0x0819080819080808, 0x081908081908082b, 0x0819080819081919, 0x0819080819082b08, 0x0819080819190819, 0x0819080819191908, 0x08190808192b0808, 0x08190808192b2b2b, 0x081908082b080819, 0x081908082b081908, 0x081908082b190808, 0x0819081908080808, 0x081908190808082b, 0x0819081908081919, 0x0819081908082b08, 0x0819081908190819, 0x0819081908191908, 0x08190819082b0808, 0x0819081919080819, 0x0819081919081908, 0x0819081919190808, 0x081908192b080808, 0x081908192b191908, 0x081908192b19192b, 0x0819082b08080819, 0x0819082b08081908, 0x0819082b0808192b, 0x0819082b08190808, 0x0819082b19080808, 0x0819082b192b0808, 0x0819190808080808, 0x081919080808082b, 0x0819190808081919, 0x0819190808082b08, 0x0819190808190819, 0x0819190808191908, 0x08191908082b0808, 0x0819190819080819, 0x0819190819081908, 0x0819190819082b19, 0x0819190819190808, 0x08191908192b1908, 0x081919082b080808, 0x0819191908080819, 0x0819191908081908, 0x0819191908190808, 0x0819191919080808, 0x0819192b08080808, 0x0819192b08191908, 0x0819192b19082b19, 0x08192b0808080819, 0x08192b0808081908, 0x08192b0808190808, 0x08192b080819082b, 0x08192b0819080808, 0x08192b0819191908, 0x08192b082b08192b, 0x08192b1908080808, 0x08192b1908081919, 0x08192b19192b192b, 0x08192b2b19190819, 0x08192b2b2b2b2b19, 0x082b080808080808, 0x082b08080808082b, 0x082b080808081919, 0x082b080808082b08, 0x082b080808082b2b, 0x082b080808190819, 0x082b080808191908, 0x082b0808082b0808, 0x082b080819080819, 0x082b080819081908, 0x082b080819190808, 0x082b08082b080808, 0x082b08082b2b0808, 0x082b081908080819, 0x082b081908081908, 0x082b081908190808, 0x082b081919080808, 0x082b081919082b08, 0x082b0819192b1919, 0x082b082b08080808, 0x082b082b082b082b, 0x082b082b2b080808, 0x082b082b2b2b2b08, 0x082b190808080819, 0x082b190808081908, 0x082b190808190808, 0x082b1908082b2b19, 0x082b190819080808, 0x082b191908080808, 0x082b191919080819, 0x082b19191919082b, 0x082b19192b192b19, 0x082b192b08080819, 0x082b192b08192b2b, 0x082b192b2b2b192b, 0x082b2b0808080808, 0x082b2b0808082b08, 0x082b2b0808082b2b, 0x082b2b08082b0808, 0x082b2b0819191919, 0x082b2b082b082b08, 0x082b2b082b2b082b, 0x082b2b19192b2b08, 0x082Line truncated
constant ulong iq2s_grid[1024] = {0x0808080808080808, 0x080808080808082b, 0x0808080808081919, 0x0808080808082b08, 0x0808080808082b2b, 0x0808080808190819, 0x0808080808191908, 0x080808080819192b, 0x0808080808192b19, 0x08080808082b0808, 0x08080808082b082b, 0x08080808082b1919, 0x08080808082b2b08, 0x0808080819080819, 0x0808080819081908, 0x080808081908192b, 0x0808080819082b19, 0x0808080819190808, 0x080808081919082b, 0x0808080819191919, 0x0808080819192b08, 0x08080808192b0819, 0x08080808192b1908, 0x08080808192b192b, 0x08080808192b2b19, 0x080808082b080808, 0x080808082b08082b, 0x080808082b081919, 0x080808082b082b08, 0x080808082b190819, 0x080808082b191908, 0x080808082b2b0808, 0x080808082b2b1919, 0x080808082b2b2b2b, 0x0808081908080819, 0x0808081908081908, 0x080808190808192b, 0x0808081908082b19, 0x0808081908190808, 0x080808190819082b, 0x0808081908191919, 0x0808081908192b08, 0x08080819082b0819, 0x08080819082b1908, 0x0808081919080808, 0x080808191908082b, 0x0808081919081919, 0x0808081919082b08, 0x0808081919190819, 0x0808081919191908, 0x080808191919192b, 0x0808081919192b19, 0x08080819192b0808, 0x08080819192b1919, 0x08080819192b2b08, 0x080808192b080819, 0x080808192b081908, 0x080808192b190808, 0x080808192b19082b, 0x080808192b191919, 0x080808192b2b0819, 0x080808192b2b1908, 0x0808082b08080808, 0x0808082b0808082b, 0x0808082b08081919, 0x0808082b08082b08, 0x0808082b08190819, 0x0808082b08191908, 0x0808082b082b0808, 0x0808082b082b2b2b, 0x0808082b19080819, 0x0808082b19081908, 0x0808082b1908192b, 0x0808082b19082b19, 0x0808082b19190808, 0x0808082b19191919, 0x0808082b2b080808, 0x0808082b2b081919, 0x0808082b2b082b2b, 0x0808082b2b191908, 0x0808082b2b2b082b, 0x0808190808080819, 0x0808190808081908, 0x080819080808192b, 0x0808190808082b19, 0x0808190808190808, 0x080819080819082b, 0x0808190808191919, 0x0808190808192b08, 0x08081908082b0819, 0x08081908082b1908, 0x08081908082b192b, 0x08081908082b2b19, 0x0808190819080808, 0x080819081908082b, 0x0808190819081919, 0x0808190819082b08, 0x0808190819082b2b, 0x0808190819190819, 0x0808190819191908, 0x080819081919192b, 0x0808190819192b19, 0x08081908192b0808, 0x08081908192b082b, 0x08081908192b1919, 0x080819082b080819, 0x080819082b081908, 0x080819082b08192b, 0x080819082b082b19, 0x080819082b190808, 0x080819082b191919, 0x080819082b192b08, 0x080819082b2b0819, 0x080819082b2b1908, 0x0808191908080808, 0x080819190808082b, 0x0808191908081919, 0x0808191908082b08, 0x0808191908082b2b, 0x0808191908190819, 0x0808191908191908, 0x080819190819192b, 0x0808191908192b19, 0x08081919082b0808, 0x08081919082b1919, 0x08081919082b2b08, 0x0808191919080819, 0x0808191919081908, 0x080819191908192b, 0x0808191919082b19, 0x0808191919190808, 0x080819191919082b, 0x0808191919191919, 0x0808191919192b08, 0x08081919192b0819, 0x08081919192b1908, 0x080819192b080808, 0x080819192b08082b, 0x080819192b081919, 0x080819192b082b08, 0x080819192b190819, 0x080819192b191908, 0x080819192b2b0808, 0x0808192b08080819, 0x0808192b08081908, 0x0808192b0808192b, 0x0808192b08082b19, 0x0808192b08190808, 0x0808192b08191919, 0x0808192b19080808, 0x0808192b19081919, 0x0808192b19082b08, 0x0808192b19190819, 0x0808192b19191908, 0x0808192b192b0808, 0x0808192b2b080819, 0x0808192b2b081908, 0x0808192b2b190808, 0x08082b0808080808, 0x08082b080808082b, 0x08082b0808081919, 0x08082b0808082b08, 0x08082b0808190819, 0x08082b0808191908, 0x08082b080819192b, 0x08082b0808192b19, 0x08082b08082b0808, 0x08082b08082b1919, 0x08082b08082b2b2b, 0x08082b0819080819, 0x08082b0819081908, 0x08082b081908192b, 0x08082b0819082b19, 0x08082b0819190808, 0x08082b081919082b, 0x08082b0819191919, 0x08082b0819192b08, 0x08082b08192b0819, 0x08082b08192b1908, 0x08082b082b080808, 0x08082b082b081919, 0x08082b082b191908, 0x08082b082b2b2b2b, 0x08082b1908080819, 0x08082b1908081908, 0x08082b1908190808, 0x08082b190819082b, 0x08082b1908191919, 0x08082b1908192b08, 0x08082b19082b0819, 0x08082b1919080808, 0x08082b1919081919, 0x08082b1919082b08, 0x08082b1919190819, 0x08082b1919191908, 0x08082b19192b0808, 0x08082b192b080819, 0x08082b192b190808, 0x08082b2b08080808, 0x08082b2b08190819, 0x08082b2b08191908, 0x08082b2b082b082b, 0x08082b2b082b2b08, 0x08082b2b082b2b2b, 0x08082b2b19190808, 0x08082b2b2b192b19, 0x0819080808080819, 0x0819080808081908, 0x081908080808192b, 0x0819080808082b19, 0x0819080808190808, 0x081908080819082b, 0x0819080808191919, 0x0819080808192b08, 0x08190808082b0819, 0x08190808082b1908, 0x08190808082b192b, 0x0819080819080808, 0x081908081908082b, 0x0819080819081919, 0x0819080819082b08, 0x0819080819190819, 0x0819080819191908, 0x081908081919192b, 0x0819080819192b19, 0x08190808192b0808, 0x08190808192b082b, 0x08190808192b1919, 0x08190808192b2b08, 0x081908082b080819, 0x081908082b081908, 0x081908082b08192b, 0x081908082b190808, 0x081908082b191919, 0x081908082b192b08, 0x081908082b2b0819, 0x081908082b2b1908, 0x0819081908080808, 0x081908190808082b, 0x0819081908081919, 0x0819081908082b08, 0x0819081908082b2b, 0x0819081908190819, 0x0819081908191908, 0x081908190819192b, 0x0819081908192b19, 0x08190819082b0808, 0x08190819082b082b, 0x081Line truncated
constant uint iq3xxs_grid[256] = {0x04040404, 0x04040414, 0x04040424, 0x04040c0c, 0x04040c1c, 0x04040c3e, 0x04041404, 0x04041414, 0x04041c0c, 0x04042414, 0x04043e1c, 0x04043e2c, 0x040c040c, 0x040c041c, 0x040c0c04, 0x040c0c14, 0x040c140c, 0x040c142c, 0x040c1c04, 0x040c1c14, 0x040c240c, 0x040c2c24, 0x040c3e04, 0x04140404, 0x04140414, 0x04140424, 0x04140c0c, 0x04141404, 0x04141414, 0x04141c0c, 0x04141c1c, 0x04141c3e, 0x04142c0c, 0x04142c3e, 0x04143e2c, 0x041c040c, 0x041c043e, 0x041c0c04, 0x041c0c14, 0x041c142c, 0x041c3e04, 0x04240c1c, 0x04241c3e, 0x04242424, 0x04242c3e, 0x04243e1c, 0x04243e2c, 0x042c040c, 0x042c043e, 0x042c1c14, 0x042c2c14, 0x04341c2c, 0x04343424, 0x043e0c04, 0x043e0c24, 0x043e0c34, 0x043e241c, 0x043e340c, 0x0c04040c, 0x0c04041c, 0x0c040c04, 0x0c040c14, 0x0c04140c, 0x0c04141c, 0x0c041c04, 0x0c041c14, 0x0c041c24, 0x0c04243e, 0x0c042c04, 0x0c0c0404, 0x0c0c0414, 0x0c0c0c0c, 0x0c0c1404, 0x0c0c1414, 0x0c14040c, 0x0c14041c, 0x0c140c04, 0x0c140c14, 0x0c14140c, 0x0c141c04, 0x0c143e14, 0x0c1c0404, 0x0c1c0414, 0x0c1c1404, 0x0c1c1c0c, 0x0c1c2434, 0x0c1c3434, 0x0c24040c, 0x0c24042c, 0x0c242c04, 0x0c2c1404, 0x0c2c1424, 0x0c2c2434, 0x0c2c3e0c, 0x0c34042c, 0x0c3e1414, 0x0c3e2404, 0x14040404, 0x14040414, 0x14040c0c, 0x14040c1c, 0x14041404, 0x14041414, 0x14041434, 0x14041c0c, 0x14042414, 0x140c040c, 0x140c041c, 0x140c042c, 0x140c0c04, 0x140c0c14, 0x140c140c, 0x140c1c04, 0x140c341c, 0x140c343e, 0x140c3e04, 0x14140404, 0x14140414, 0x14140c0c, 0x14140c3e, 0x14141404, 0x14141414, 0x14141c3e, 0x14142404, 0x14142c2c, 0x141c040c, 0x141c0c04, 0x141c0c24, 0x141c3e04, 0x141c3e24, 0x14241c2c, 0x14242c1c, 0x142c041c, 0x142c143e, 0x142c240c, 0x142c3e24, 0x143e040c, 0x143e041c, 0x143e0c34, 0x143e242c, 0x1c04040c, 0x1c040c04, 0x1c040c14, 0x1c04140c, 0x1c04141c, 0x1c042c04, 0x1c04342c, 0x1c043e14, 0x1c0c0404, 0x1c0c0414, 0x1c0c1404, 0x1c0c1c0c, 0x1c0c2424, 0x1c0c2434, 0x1c14040c, 0x1c14041c, 0x1c140c04, 0x1c14142c, 0x1c142c14, 0x1c143e14, 0x1c1c0c0c, 0x1c1c1c1c, 0x1c241c04, 0x1c24243e, 0x1c243e14, 0x1c2c0404, 0x1c2c0434, 0x1c2c1414, 0x1c2c2c2c, 0x1c340c24, 0x1c341c34, 0x1c34341c, 0x1c3e1c1c, 0x1c3e3404, 0x24040424, 0x24040c3e, 0x24041c2c, 0x24041c3e, 0x24042c1c, 0x24042c3e, 0x240c3e24, 0x24141404, 0x24141c3e, 0x24142404, 0x24143404, 0x24143434, 0x241c043e, 0x241c242c, 0x24240424, 0x24242c0c, 0x24243424, 0x242c142c, 0x242c241c, 0x242c3e04, 0x243e042c, 0x243e0c04, 0x243e0c14, 0x243e1c04, 0x2c040c14, 0x2c04240c, 0x2c043e04, 0x2c0c0404, 0x2c0c0434, 0x2c0c1434, 0x2c0c2c2c, 0x2c140c24, 0x2c141c14, 0x2c143e14, 0x2c1c0414, 0x2c1c2c1c, 0x2c240c04, 0x2c24141c, 0x2c24143e, 0x2c243e14, 0x2c2c0414, 0x2c2c1c0c, 0x2c342c04, 0x2c3e1424, 0x2c3e2414, 0x34041424, 0x34042424, 0x34042434, 0x34043424, 0x340c140c, 0x340c340c, 0x34140c3e, 0x34143424, 0x341c1c04, 0x341c1c34, 0x34242424, 0x342c042c, 0x342c2c14, 0x34341c1c, 0x343e041c, 0x343e140c, 0x3e04041c, 0x3e04042c, 0x3e04043e, 0x3e040c04, 0x3e041c14, 0x3e042c14, 0x3e0c1434, 0x3e0c2404, 0x3e140c14, 0x3e14242c, 0x3e142c14, 0x3e1c0404, 0x3e1c0c2c, 0x3e1c1c1c, 0x3e1c3404, 0x3e24140c, 0x3e24240c, 0x3e2c0404, 0x3e2c0414, 0x3e2c1424, 0x3e341c04,};
constant uint iq3s_grid[512] = {0x01010101, 0x01010103, 0x01010105, 0x0101010b, 0x0101010f, 0x01010301, 0x01010303, 0x01010305, 0x01010309, 0x0101030d, 0x01010501, 0x01010503, 0x0101050b, 0x01010707, 0x01010901, 0x01010905, 0x0101090b, 0x0101090f, 0x01010b03, 0x01010b07, 0x01010d01, 0x01010d05, 0x01010f03, 0x01010f09, 0x01010f0f, 0x01030101, 0x01030103, 0x01030105, 0x01030109, 0x01030301, 0x01030303, 0x0103030b, 0x01030501, 0x01030507, 0x0103050f, 0x01030703, 0x0103070b, 0x01030909, 0x01030d03, 0x01030d0b, 0x01030f05, 0x01050101, 0x01050103, 0x0105010b, 0x0105010f, 0x01050301, 0x01050307, 0x0105030d, 0x01050503, 0x0105050b, 0x01050701, 0x01050709, 0x01050905, 0x0105090b, 0x0105090f, 0x01050b03, 0x01050b07, 0x01050f01, 0x01050f07, 0x01070107, 0x01070303, 0x0107030b, 0x01070501, 0x01070505, 0x01070703, 0x01070707, 0x0107070d, 0x01070909, 0x01070b01, 0x01070b05, 0x01070d0f, 0x01070f03, 0x01070f0b, 0x01090101, 0x01090307, 0x0109030f, 0x01090503, 0x01090509, 0x01090705, 0x01090901, 0x01090907, 0x01090b03, 0x01090f01, 0x010b0105, 0x010b0109, 0x010b0501, 0x010b0505, 0x010b050d, 0x010b0707, 0x010b0903, 0x010b090b, 0x010b090f, 0x010b0d0d, 0x010b0f07, 0x010d010d, 0x010d0303, 0x010d0307, 0x010d0703, 0x010d0b05, 0x010d0f03, 0x010f0101, 0x010f0105, 0x010f0109, 0x010f0501, 0x010f0505, 0x010f050d, 0x010f0707, 0x010f0b01, 0x010f0b09, 0x03010101, 0x03010103, 0x03010105, 0x03010109, 0x03010301, 0x03010303, 0x03010307, 0x0301030b, 0x0301030f, 0x03010501, 0x03010505, 0x03010703, 0x03010709, 0x0301070d, 0x03010b09, 0x03010b0d, 0x03010d03, 0x03010f05, 0x03030101, 0x03030103, 0x03030107, 0x0303010d, 0x03030301, 0x03030309, 0x03030503, 0x03030701, 0x03030707, 0x03030903, 0x03030b01, 0x03030b05, 0x03030f01, 0x03030f0d, 0x03050101, 0x03050305, 0x0305030b, 0x0305030f, 0x03050501, 0x03050509, 0x03050705, 0x03050901, 0x03050907, 0x03050b0b, 0x03050d01, 0x03050f05, 0x03070103, 0x03070109, 0x0307010f, 0x03070301, 0x03070307, 0x03070503, 0x0307050f, 0x03070701, 0x03070709, 0x03070903, 0x03070d05, 0x03070f01, 0x03090107, 0x0309010b, 0x03090305, 0x03090309, 0x03090703, 0x03090707, 0x03090905, 0x0309090d, 0x03090b01, 0x03090b09, 0x030b0103, 0x030b0301, 0x030b0307, 0x030b0503, 0x030b0701, 0x030b0705, 0x030b0b03, 0x030d0501, 0x030d0509, 0x030d050f, 0x030d0909, 0x030d090d, 0x030f0103, 0x030f0107, 0x030f0301, 0x030f0305, 0x030f0503, 0x030f070b, 0x030f0903, 0x030f0d05, 0x030f0f01, 0x05010101, 0x05010103, 0x05010107, 0x0501010b, 0x0501010f, 0x05010301, 0x05010305, 0x05010309, 0x0501030d, 0x05010503, 0x05010507, 0x0501050f, 0x05010701, 0x05010705, 0x05010903, 0x05010907, 0x0501090b, 0x05010b01, 0x05010b05, 0x05010d0f, 0x05010f01, 0x05010f07, 0x05010f0b, 0x05030101, 0x05030105, 0x05030301, 0x05030307, 0x0503030f, 0x05030505, 0x0503050b, 0x05030703, 0x05030709, 0x05030905, 0x05030b03, 0x05050103, 0x05050109, 0x0505010f, 0x05050503, 0x05050507, 0x05050701, 0x0505070f, 0x05050903, 0x05050b07, 0x05050b0f, 0x05050f03, 0x05050f09, 0x05070101, 0x05070105, 0x0507010b, 0x05070303, 0x05070505, 0x05070509, 0x05070703, 0x05070707, 0x05070905, 0x05070b01, 0x05070d0d, 0x05090103, 0x0509010f, 0x05090501, 0x05090507, 0x05090705, 0x0509070b, 0x05090903, 0x05090f05, 0x05090f0b, 0x050b0109, 0x050b0303, 0x050b0505, 0x050b070f, 0x050b0901, 0x050b0b07, 0x050b0f01, 0x050d0101, 0x050d0105, 0x050d010f, 0x050d0503, 0x050d0b0b, 0x050d0d03, 0x050f010b, 0x050f0303, 0x050f050d, 0x050f0701, 0x050f0907, 0x050f0b01, 0x07010105, 0x07010303, 0x07010307, 0x0701030b, 0x0701030f, 0x07010505, 0x07010703, 0x07010707, 0x0701070b, 0x07010905, 0x07010909, 0x0701090f, 0x07010b03, 0x07010d07, 0x07010f03, 0x07030103, 0x07030107, 0x0703010b, 0x07030309, 0x07030503, 0x07030507, 0x07030901, 0x07030d01, 0x07030f05, 0x07030f0d, 0x07050101, 0x07050305, 0x07050501, 0x07050705, 0x07050709, 0x07050b01, 0x07070103, 0x07070301, 0x07070309, 0x07070503, 0x07070507, 0x0707050f, 0x07070701, 0x07070903, 0x07070907, 0x0707090f, 0x07070b0b, 0x07070f07, 0x07090107, 0x07090303, 0x0709030d, 0x07090505, 0x07090703, 0x07090b05, 0x07090d01, 0x07090d09, 0x070b0103, 0x070b0301, 0x070b0305, 0x070b050b, 0x070b0705, 0x070b0909, 0x070b0b0d, 0x070b0f07, 0x070d030d, 0x070d0903, 0x070f0103, 0x070f0107, 0x070f0501, 0x070f0505, 0x070f070b, 0x09010101, 0x09010109, 0x09010305, 0x09010501, 0x09010509, 0x0901050f, 0x09010705, 0x09010903, 0x09010b01, 0x09010f01, 0x09030105, 0x0903010f, 0x09030303, 0x09030307, 0x09030505, 0x09030701, 0x0903070b, 0x09030907, 0x09030b03, 0x09030b0b, 0x09050103, 0x09050107, 0x09050301, 0x0905030b, 0x09050503, 0x09050707, 0x09050901, 0x09050b0f, 0x09050d05, 0x09050f01, 0x09070109, 0x09070303, 0x09070307, 0x09070501, 0x09070505, 0x09070703, 0x0907070b, 0x09090101, 0x09090105, 0x09090509, 0x0909070f, 0x09090901, 0x09090f03, 0x090b010b, 0x090b010f, 0x090b0503, 0x090b0d05, 0x090d0307, 0x090d0709, 0x090d0d01, 0x090f0301, 0x090f030b, 0x090f0701, 0x090f0907, 0x090f0b03, 0x0b010105, 0x0b010301, 0x0b010309, 0x0b010505, 0x0b010901, 0x0b010909, 0x0b01090f, 0x0b010b05, 0x0b010d0d, 0x0b010f09, 0x0b030103, 0x0b030107,Line truncated
+16
View File
@@ -0,0 +1,16 @@
// metal/src/engine/stubs_ds4.cpp - CPU-kernel entry points the DeepSeek engine (strata_ds4) references that have no Apple
// implementation. strata::ds4::gguf::dots() asks iq256x_supported() before it uses iq256x_rows(); on arm64 the answer is no and the
// CPU lane keeps ggml-cpu's rows, which have their own NEON kernels.
#include "strata/kernels/cpu/iq_avx2.hpp"
#include <cstdlib>
namespace strata::kernels::cpu {
bool iq256x_supported(int) noexcept { return false; }
void iq256x_rows(int, const uint8_t*, size_t, int, const void* const*, int, float* const*, int, int) {
std::abort(); // unreachable: iq256x_supported() is false here, and callers check it
}
} // namespace strata::kernels::cpu
+218 -1
View File
@@ -24,11 +24,21 @@
#include <atomic>
#include <chrono>
#include <cstdio>
#if defined(STRATA_DS4_RUNTIME)
#include <cstdlib>
#endif
#include <cstring>
#include <iterator>
#include <map>
#if defined(STRATA_DS4_RUNTIME)
#include <mutex> // recursive_mutex
#else
#include <mutex>
#endif
#include <thread>
#if defined(STRATA_DS4_RUNTIME)
#include <algorithm>
#endif
#include <vector>
namespace strata::gpu {
@@ -66,6 +76,9 @@ struct Op {
struct EventImpl {
MTL::SharedEvent* ev = nullptr; // the recording stream's timeline
uint64_t value = 0; // 0: never recorded (complete, as a fresh CUDA event)
#if defined(STRATA_DS4_RUNTIME)
void* token = nullptr; // a mark on the context's queue (ds4_ctx_mark), released by the wait
#endif
};
struct GraphImpl {
@@ -118,8 +131,16 @@ Error fail(Error e) {
return e;
}
#if defined(STRATA_DS4_RUNTIME)
static std::vector<StreamImpl*> g_all_streams; // every stream's queue holds pending copies until it is committed
static std::mutex g_all_streams_mu;
#endif
StreamImpl* make_stream() {
auto* s = new StreamImpl;
#if defined(STRATA_DS4_RUNTIME)
{ std::lock_guard<std::mutex> lk(g_all_streams_mu); g_all_streams.push_back(s); }
#endif
s->q = rt().dev->newCommandQueue();
s->q->addResidencySet(rt().rs);
s->tl = rt().dev->newSharedEvent();
@@ -154,30 +175,83 @@ const Alloc* find(const void* p, size_t* off) {
return nullptr;
}
#if defined(STRATA_DS4_RUNTIME)
// The engine's host threads (the prompt path, the loader, the expert cache) have no autorelease pool, and metal-cpp's
// commandBuffer() / computeCommandEncoder() / blitCommandEncoder() hand back autoreleased objects: with no pool that last reference was
// never given back, so every command buffer and encoder stayed alive with the buffers it had named (~800 command buffers a
// long prompt). A pool around each creation (and, for the blits, around the whole encode) gives it back.
struct ScopedPool {
NS::AutoreleasePool* p;
ScopedPool() : p(NS::AutoreleasePool::alloc()->init()) {}
~ScopedPool() { p->release(); }
};
#endif
MTL::CommandBuffer* open_cb(StreamImpl* s) {
if (!s->cb) {
#if defined(STRATA_DS4_RUNTIME)
ScopedPool pool;
#endif
s->cb = s->q->commandBufferWithUnretainedReferences();
s->cb->retain();
++rt().stats.command_buffers;
}
return s->cb;
}
#if defined(STRATA_DS4_RUNTIME)
// StreamImpl's encoder/command-buffer state is touched from the loader, the prefetcher and the compute path.
// Without this lock two encoders can be open on one command buffer at once, which Metal asserts on.
static std::recursive_mutex g_stream_mu; // encode_copy/flush nest
#endif
void end_encoder(StreamImpl* s) {
#if defined(STRATA_DS4_RUNTIME)
if (s->enc) { if (std::getenv("STRATA_GPU_TRACE")) std::fprintf(stderr, "[gpu] close compute enc %p\n", (void*) s->enc);
s->enc->endEncoding(); s->enc->release(); s->enc = nullptr; }
#else
if (s->enc) { s->enc->endEncoding(); s->enc = nullptr; }
#endif
}
MTL::ComputeCommandEncoder* encoder(StreamImpl* s) {
#if defined(STRATA_DS4_RUNTIME)
std::lock_guard<std::recursive_mutex> lk(g_stream_mu);
if (!s->enc) {
MTL::CommandBuffer* cb = open_cb(s);
{ ScopedPool pool; s->enc = cb->computeCommandEncoder(); s->enc->retain(); } // (released in end_encoder)
if (std::getenv("STRATA_GPU_TRACE")) std::fprintf(stderr, "[gpu] open compute enc %p (cb %p)\n", (void*) s->enc, (void*) s->cb); } // serial dispatch: each dispatch sees the last
#else
if (!s->enc) s->enc = open_cb(s)->computeCommandEncoder(); // serial dispatch: each dispatch sees the last
#endif
return s->enc;
}
void flush(StreamImpl* s) {
#if defined(STRATA_DS4_RUNTIME)
std::lock_guard<std::recursive_mutex> lk(g_stream_mu);
#endif
if (!s->cb) return;
end_encoder(s);
#if defined(STRATA_DS4_RUNTIME)
if (std::getenv("STRATA_GPU_TRACE")) std::fprintf(stderr, "[gpu] signal %llu on cb %p (enc %p)\n",
(unsigned long long) (s->submitted + 1), (void*) s->cb, (void*) s->enc);
#endif
s->cb->encodeSignalEvent(s->tl, ++s->submitted);
s->cb->commit();
s->cb->release();
s->cb = nullptr;
s->pending = 0;
}
#if defined(STRATA_DS4_RUNTIME)
extern "C" void ds4_metal_flush();
extern "C" bool ds4_ctx_copy(void* dst, const void* src, size_t n) __attribute__((weak_import));
extern "C" bool ds4_ctx_fill(void* dst, int value, size_t n) __attribute__((weak_import));
extern "C" void* ds4_ctx_mark() __attribute__((weak_import));
extern "C" void ds4_ctx_wait(void* token) __attribute__((weak_import));
extern "C" void ds4_ctx_drop(void* token) __attribute__((weak_import));
static bool ctx_queue_ops() { // DS4_STREAM_COPIES=1: the old way (a copy on a stream's own queue, a full barrier each)
static const bool on = ds4_ctx_copy != nullptr && std::getenv("DS4_STREAM_COPIES") == nullptr;
return on;
}
#endif
Error wait_value(MTL::SharedEvent* ev, uint64_t v) {
while (ev->signaledValue() < v) std::this_thread::yield();
++rt().stats.host_syncs;
@@ -195,6 +269,9 @@ void check_purged(const Alloc* a, const char* what) {
}
Error encode_copy(StreamImpl* s, void* dst, const void* src, size_t n) {
#if defined(STRATA_DS4_RUNTIME)
std::lock_guard<std::recursive_mutex> lk(g_stream_mu);
#endif
size_t od = 0, os = 0;
const Alloc* ad = find(dst, &od);
const Alloc* as = find(src, &os);
@@ -202,7 +279,13 @@ Error encode_copy(StreamImpl* s, void* dst, const void* src, size_t n) {
check_purged(as, "copy");
if (ad && as) { // buffer to buffer: a blit, in stream order
end_encoder(s);
#if defined(STRATA_DS4_RUNTIME)
ScopedPool pool;
#endif
MTL::BlitCommandEncoder* b = open_cb(s)->blitCommandEncoder();
#if defined(STRATA_DS4_RUNTIME)
if (std::getenv("STRATA_GPU_TRACE")) std::fprintf(stderr, "[gpu] blit copy %zu\n", n);
#endif
b->copyFromBuffer(as->buf, os, ad->buf, od, n);
b->endEncoding();
++rt().stats.blits;
@@ -219,11 +302,17 @@ Error encode_copy(StreamImpl* s, void* dst, const void* src, size_t n) {
}
Error encode_fill(StreamImpl* s, void* p, int value, size_t n) {
#if defined(STRATA_DS4_RUNTIME)
std::lock_guard<std::recursive_mutex> lk(g_stream_mu);
#endif
size_t off = 0;
const Alloc* a = find(p, &off);
if (!a) return fail(kErrorInvalidValue);
check_purged(a, "fill");
end_encoder(s);
#if defined(STRATA_DS4_RUNTIME)
ScopedPool pool;
#endif
MTL::BlitCommandEncoder* b = open_cb(s)->blitCommandEncoder();
b->fillBuffer(a->buf, NS::Range::Make(off, n), (uint8_t) value);
b->endEncoding();
@@ -232,6 +321,9 @@ Error encode_fill(StreamImpl* s, void* p, int value, size_t n) {
}
Error encode_dispatch(StreamImpl* s, const Op& o) {
#if defined(STRATA_DS4_RUNTIME)
std::lock_guard<std::recursive_mutex> lk(g_stream_mu);
#endif
MTL::ComputeCommandEncoder* e = encoder(s);
e->setComputePipelineState(o.pipe);
if (o.big_args.empty()) e->setBytes(o.args, o.arg_bytes, 0);
@@ -277,7 +369,12 @@ void alloc_log(const char* what, size_t n) {
Error new_alloc(void** p, size_t bytes, bool host) {
Runtime& r = rt();
#if defined(STRATA_DS4_RUNTIME)
const size_t pg = 16384, want = bytes ? bytes : 1;
const size_t n = std::getenv("DS4_NO_PAD") ? want : ((want + pg - 1) / pg) * pg;
#else
const size_t n = bytes ? bytes : 1;
#endif
MTL::Buffer* b = r.dev->newBuffer(n, MTL::ResourceStorageModeShared | MTL::ResourceHazardTrackingModeUntracked);
if (!b) return fail(kErrorMemoryAllocation);
Alloc a{b, b->gpuAddress(), (uint8_t*) b->contents(), n, host};
@@ -299,6 +396,9 @@ Error free_alloc(void* p) {
size_t off = 0;
const Alloc* a = find(p, &off);
if (!a || off != 0) return fail(kErrorInvalidValue);
#if defined(STRATA_DS4_RUNTIME)
if (std::getenv("DS4_TRACE_FREE")) std::fprintf(stderr, "gpu free %zu bytes (host=%d) at %p\n", a->bytes, (int) a->host, p);
#endif
device_synchronize(); // as cudaFree: the GPU is done with it
std::lock_guard<std::mutex> lk(r.mu);
MTL::Buffer* b = a->buf;
@@ -314,6 +414,22 @@ Error free_alloc(void* p) {
} // namespace
// ---------------------------------------------------------------------------------------------------- runtime.hpp
#if defined(STRATA_DS4_RUNTIME)
void* buffer_for_cpu_impl(const void* cpu, size_t bytes, size_t* off) {
Runtime& r = rt();
std::lock_guard<std::mutex> lk(r.mu);
auto ic = r.by_cpu.upper_bound((uintptr_t) cpu);
if (ic == r.by_cpu.begin()) return nullptr;
--ic;
const Alloc& a = r.by_gpu[ic->second];
if ((uintptr_t) cpu < ic->first || (uintptr_t) cpu + bytes > ic->first + a.bytes) return nullptr;
*off = (uintptr_t) cpu - ic->first;
return (void*) a.buf;
}
MTL::ResidencySet* residency_set_impl() { return rt().rs; }
#endif
void* host_view(const void* p) {
size_t off = 0;
const Alloc* a = find(p, &off);
@@ -358,6 +474,9 @@ Error get_device(int* dev) { *dev = 0; return kSuccess; }
Error set_device(int dev) { return dev == 0 ? kSuccess : fail(kErrorInvalidValue); }
Error device_count(int* n) { *n = rt().dev ? 1 : 0; return kSuccess; }
Error device_synchronize() {
#if defined(STRATA_DS4_RUNTIME)
ds4_metal_flush();
#endif
Runtime& r = rt();
StreamImpl* s = S(nullptr);
flush(s);
@@ -427,7 +546,30 @@ Error host_device_pointer_raw(void** dev, void* host) {
*dev = (void*) (uintptr_t) (a->gpu + off);
return kSuccess;
}
#if defined(STRATA_DS4_RUNTIME)
// A synchronising call is a barrier for BOTH queues: the dispatches deferred in mtl_rt's command buffer have to run before
// the host reads or overwrites what they touch (a window's last layers still pending while the next window's embedding rows
// are copied over the buffer its embed reads, measured: window 2's residual stream was window 1's stale tail).
// commit every stream's pending copies/fills, then wait for all of them (commit first: a stream may wait on another's event)
extern "C" void ds4_gpu_sync_all_streams() {
std::vector<StreamImpl*> v;
{ std::lock_guard<std::mutex> lk(g_all_streams_mu); v = g_all_streams; }
for (StreamImpl* s : v) if (!s->rec) flush(s);
for (StreamImpl* s : v) if (!s->rec) wait_value(s->tl, s->submitted);
}
static thread_local bool t_in_barrier = false;
static void both_queues_barrier() {
if (t_in_barrier) return;
t_in_barrier = true;
ds4_metal_flush();
t_in_barrier = false;
}
#endif
Error memset_device(void* p, int value, size_t bytes) {
#if defined(STRATA_DS4_RUNTIME)
both_queues_barrier();
#endif
StreamImpl* s = S(nullptr);
const Error e = encode_fill(s, p, value, bytes);
flush(s);
@@ -435,6 +577,13 @@ Error memset_device(void* p, int value, size_t bytes) {
return e;
}
Error memset_async(void* p, int value, size_t bytes, Stream st) {
#if defined(STRATA_DS4_RUNTIME)
if (!S(st)->rec && ctx_queue_ops() && bytes > 0) {
size_t o1 = 0;
if (find(p, &o1) && ds4_ctx_fill(p, value, bytes)) return kSuccess;
}
if (!S(st)->rec && !std::getenv("DS4_NO_QBARRIER")) both_queues_barrier();
#endif
StreamImpl* s = S(st);
if (s->rec) {
Op o;
@@ -446,6 +595,9 @@ Error memset_async(void* p, int value, size_t bytes, Stream st) {
}
Error memcpy_sync(void* dst, const void* src, size_t bytes, CopyKind) {
#if defined(STRATA_DS4_RUNTIME)
both_queues_barrier();
#endif
StreamImpl* s = S(nullptr);
const Error e = encode_copy(s, dst, src, bytes);
flush(s);
@@ -464,6 +616,13 @@ Error memcpy_peer_async(void* dst, int dst_dev, const void* src, int src_dev, si
return memcpy_async(dst, src, bytes, kDeviceToDevice, st);
}
Error memcpy_async(void* dst, const void* src, size_t bytes, CopyKind, Stream st) {
#if defined(STRATA_DS4_RUNTIME)
if (!S(st)->rec && ctx_queue_ops() && bytes > 0) {
size_t o1 = 0, o2 = 0;
if (find(dst, &o1) && find(src, &o2) && ds4_ctx_copy(dst, src, bytes)) return kSuccess;
}
if (!S(st)->rec && !std::getenv("DS4_NO_QBARRIER")) both_queues_barrier();
#endif
StreamImpl* s = S(st);
if (s->rec) {
Op o;
@@ -479,12 +638,19 @@ Error stream_destroy(Stream s) {
if (!s) return kSuccess;
flush(s);
wait_value(s->tl, s->submitted);
#if defined(STRATA_DS4_RUNTIME)
{ std::lock_guard<std::mutex> lk(g_all_streams_mu);
g_all_streams.erase(std::remove(g_all_streams.begin(), g_all_streams.end(), s), g_all_streams.end()); }
#endif
s->tl->release();
s->q->release();
delete s;
return kSuccess;
}
Error stream_synchronize(Stream st) {
#if defined(STRATA_DS4_RUNTIME)
both_queues_barrier();
#endif
StreamImpl* s = S(st);
flush(s);
return wait_value(s->tl, s->submitted);
@@ -516,8 +682,23 @@ Error launch_host_func(Stream st, HostFn fn, void* arg) {
}
Error event_create(Event* e, unsigned) { *e = new EventImpl; return kSuccess; }
Error event_destroy(Event e) { delete e; return kSuccess; }
Error event_destroy(Event e) {
#if defined(STRATA_DS4_RUNTIME)
if (e && e->token && ds4_ctx_drop) { ds4_ctx_drop(e->token); e->token = nullptr; } // (a mark nobody waited for holds its command buffer)
#endif
delete e;
return kSuccess;
}
Error event_record(Event e, Stream st) {
#if defined(STRATA_DS4_RUNTIME)
if (ctx_queue_ops() && !S(st)->rec) { // the work the event follows is in the context's command buffer: end it, wait later
if (e->token && ds4_ctx_drop) ds4_ctx_drop(e->token); // (recorded again before it was waited for: the earlier mark would leak its command buffer)
e->token = ds4_ctx_mark();
e->ev = nullptr;
e->value = 0;
return kSuccess;
}
#endif
StreamImpl* s = S(st);
if (s->rec) {
Op o;
@@ -527,8 +708,24 @@ Error event_record(Event e, Stream st) {
}
return record_event(e, s);
}
#if defined(STRATA_DS4_RUNTIME)
Error event_query(Event e) {
if (e->token) return kNotReady; // (a mark waits for its wait)
return !e->ev || e->ev->signaledValue() >= e->value ? kSuccess : kNotReady;
}
Error event_synchronize(Event e) {
if (e->token) {
void* t = e->token;
e->token = nullptr;
ds4_ctx_wait(t);
return kSuccess;
}
return e->ev ? wait_value(e->ev, e->value) : kSuccess;
}
#else
Error event_query(Event e) { return !e->ev || e->ev->signaledValue() >= e->value ? kSuccess : kNotReady; }
Error event_synchronize(Event e) { return e->ev ? wait_value(e->ev, e->value) : kSuccess; }
#endif
Error event_elapsed_ms(float* ms, Event, Event) { *ms = 0.0f; return fail(kErrorUnsupported); }
Error begin_capture(Stream st) {
@@ -638,3 +835,23 @@ Stats stats() { return rt().stats; }
} // namespace metal
} // namespace strata::gpu
#if defined(STRATA_DS4_RUNTIME)
extern "C" void* ds4_gpu_buffer_for_cpu(const void* cpu, size_t bytes, size_t* off) {
return strata::gpu::buffer_for_cpu_impl(cpu, bytes, off);
}
// The residency set every strata::gpu stream queue carries: the Context's queue (mtl_rt) needs it too, because kernels
// dereference raw gpuAddresses (weights, ring buffers, job arrays) that no setBuffer names, and memory that is not resident
// reads as zeros.
extern "C" void* ds4_gpu_residency_set() { return (void*) strata::gpu::residency_set_impl(); }
// bytes from `cpu` to the end of the allocation that holds it (0: not an allocation)
extern "C" size_t ds4_gpu_alloc_remaining(const void* cpu) {
size_t off = 0;
void* b = strata::gpu::buffer_for_cpu_impl(cpu, 1, &off);
if (!b) return 0;
return ((MTL::Buffer*) b)->length() - off;
}
#endif
+16
View File
@@ -22,6 +22,7 @@ struct Buffer {
// a buffer longer than maxBufferLength: overlapping chunks every `stride` bytes (see Context::wrap)
std::shared_ptr<std::vector<void*>> chunks;
size_t stride = 0;
size_t bias = 0; // bytes from the MTLBuffer's start to ptr (a view onto an allocation the runtime owns)
template <class T> T* as() const { return static_cast<T*>(ptr); }
};
@@ -82,6 +83,16 @@ public:
bool supports_metal4() const;
uint64_t recommended_working_set() const;
uint64_t max_buffer_length() const;
// Keeps the GPU out of its low-clock idle state while dispatches keep coming (a lone kernel in a quiet GPU runs 2-3x slower:
// measured 205 vs 69 us for the same matvec): a spin kernel of one threadgroup on a second queue, stopped 50 ms after the
// last dispatch. DS4_NO_KEEPALIVE=1: off.
void start_keepalive();
// Zero-copy weights: the GPU address of host memory [p, p + bytes) (file-backed pages of the model, RAM slots) through a
// no-copy buffer over the pages it spans, made resident. 0: not mappable (the cap on buffers is reached, or the pages are not
// ours). The first dispatch that reads cold pages waits for them (the buffer is wired on use).
uint64_t zc_map(const void* p, size_t bytes);
Pipeline pipeline_uncached(int lib, const std::string& fn, const Constants& c);
size_t zc_count() const;
uint64_t allocated() const { return allocated_; } // bytes of alloc() + wrap() buffers so far
// Compiles a library from MSL source files (paths), prepended by `prelude` (defines) and, if include_ggml,
@@ -100,6 +111,11 @@ public:
// Encodes `ds` into ONE compute encoder (serial or concurrent dispatch type) and ONE command buffer, `reps`
// times over; commits and waits. Returns the GPU time (GPUEndTime - GPUStartTime) in seconds.
void* mark(); // split_runs() that returns a token for the buffer just committed (nullptr: nothing was queued)
void wait_mark(void* token); // block until that buffer has completed, then drop the token
void drop_mark(void* token); // drop a token without waiting (an event recorded again, or destroyed, before it was waited on)
void split_runs(); // commit the batched dispatches as their own command buffer, without waiting
void flush_runs(); // commit and wait for the dispatches batched since the last flush
double run(const std::vector<Dispatch>& ds, int reps = 1, bool concurrent = false, bool barrier_between = false);
// A dependency graph in ONE concurrent encoder: a memory barrier only before dispatches with `barrier` set, so
// the dispatches between two barriers may overlap. Returns the GPU time in seconds.
+691
View File
@@ -6,6 +6,18 @@
#include "resource_path.h"
#include <chrono>
#if defined(STRATA_DS4_RUNTIME)
#include <execinfo.h>
#include <algorithm>
#include <atomic>
#include <mutex>
#include <unordered_map>
#include <string>
#include <deque>
#include <mach/mach_time.h>
#include <map>
#include <thread>
#endif
#include <cstdio>
#include <cstdlib>
#include <fstream>
@@ -28,7 +40,21 @@ std::string read_file(const std::string& path) {
return ss.str();
}
#if defined(STRATA_DS4_RUNTIME)
extern "C" void* ds4_gpu_residency_set() __attribute__((weak_import));
#endif
struct Context::Impl {
#if defined(STRATA_DS4_RUNTIME)
id<MTLCommandBuffer> cb_def = nil; // the open command buffer single dispatches accumulate in
id<MTLComputeCommandEncoder> enc_def = nil;
int def_n = 0;
std::string def_names, inflight_names; // (DS4_ERRINFO) the kernels of the open / the committed command buffer
id<MTLSharedEvent> qev = nil; // consecutive deferred buffers are chained through it (untracked buffers: the queue alone does not order them)
uint64_t qev_v = 0;
id<MTLCommandBuffer> inflight = nil; // the last committed-but-not-waited deferred buffer (the queue runs them in order)
std::vector<void*> graveyard; // buffers freed while the deferred command buffer still references them (it holds none)
std::recursive_mutex mu; // helper threads (the stager, the expert cache) flush too: one writer on the open encoder
#endif
id<MTLDevice> dev;
id<MTLCommandQueue> q;
std::vector<id<MTLLibrary>> libs;
@@ -39,7 +65,11 @@ struct Context::Impl {
static inline id<MTLBuffer> B(const Buffer& b) { return (__bridge id<MTLBuffer>)b.handle; }
// the MTLBuffer and offset that bind byte `off` of b (a chunked buffer: the chunk the offset selects)
static inline id<MTLBuffer> BO(const Buffer& b, size_t off, size_t& o) {
#if defined(STRATA_DS4_RUNTIME)
if (!b.chunks) { o = off + b.bias; return B(b); }
#else
if (!b.chunks) { o = off; return B(b); }
#endif
const size_t k = std::min(off / b.stride, b.chunks->size() - 1);
o = off - k * b.stride;
return (__bridge id<MTLBuffer>)(*b.chunks)[k];
@@ -47,13 +77,243 @@ static inline id<MTLBuffer> BO(const Buffer& b, size_t off, size_t& o) {
static inline id<MTLComputePipelineState> P(const Pipeline* p) { return (__bridge id<MTLComputePipelineState>)p->handle; }
static std::string g_device_name = "unknown GPU";
#if defined(STRATA_DS4_RUNTIME)
// ---- DS4_TIMELINE=1: every deferred dispatch in its own encoder with start / end GPU timestamps; the report at exit gives, per kernel,
// its GPU time and the gap the GPU sat idle before it (host waits, event waits, launch). DS4_TL_FROM=N: only from dispatch N on.
static std::atomic<uint64_t> g_disp_count{0}; // dispatches encoded
namespace {
struct Timeline {
bool on = false;
id<MTLCounterSet> tset = nil;
std::vector<id<MTLCounterSampleBuffer>> bufs; // a sample buffer holds only ~1500 encoders: they rotate
size_t per = 0, cap = 0, n = 0; // encoders per buffer / in all (the buffers' limit) / recorded
long after = 0; // DS4_TL_AFTER: record from the Nth dispatch on
std::vector<std::string> names; // per encoder
uint64_t cpu0 = 0, gpu0 = 0;
bool reg = false;
bool gate = true; // ds4_timeline_gate: record only while open
bool att = false; // the encoder just made carries a sample slot
} g_tl;
id<MTLComputeCommandEncoder> tl_make_encoder(id<MTLDevice> dev, id<MTLCommandBuffer> cb, MTLDispatchType dt) {
g_tl.att = false;
if (!g_tl.on) return [cb computeCommandEncoderWithDispatchType:dt];
if (g_tl.tset == nil) {
for (id<MTLCounterSet> cs in dev.counterSets)
if ([cs.name isEqualToString:MTLCommonCounterSetTimestamp]) g_tl.tset = cs;
if (g_tl.tset == nil) {
std::fprintf(stderr, "mtl: DS4_TIMELINE: no timestamp counter set\n");
g_tl.on = false;
return [cb computeCommandEncoderWithDispatchType:dt];
}
for (size_t c = 4096; c >= 64 && g_tl.per == 0; c /= 2) {
MTLCounterSampleBufferDescriptor* d = [MTLCounterSampleBufferDescriptor new];
d.counterSet = g_tl.tset;
d.storageMode = MTLStorageModeShared;
d.sampleCount = c * 2;
NSError* err = nil;
id<MTLCounterSampleBuffer> b = [dev newCounterSampleBufferWithDescriptor:d error:&err];
if (b != nil) {
g_tl.per = c;
g_tl.bufs.push_back(b);
}
}
if (g_tl.per == 0) {
std::fprintf(stderr, "mtl: DS4_TIMELINE: no counter sample buffer\n");
g_tl.on = false;
return [cb computeCommandEncoderWithDispatchType:dt];
}
g_tl.cap = g_tl.per * 120;
MTLTimestamp c0 = 0, g0 = 0;
[dev sampleTimestamps:&c0 gpuTimestamp:&g0];
g_tl.cpu0 = c0;
g_tl.gpu0 = g0;
g_tl.names.assign(g_tl.cap, std::string());
g_tl.after = std::getenv("DS4_TL_AFTER") ? std::atol(std::getenv("DS4_TL_AFTER")) : 0;
if (std::getenv("DS4_TL_DRAFT")) g_tl.gate = false;
}
if (!g_tl.gate || g_tl.n >= g_tl.cap || (long) g_disp_count.load() < g_tl.after) return [cb computeCommandEncoderWithDispatchType:dt];
const size_t bi = g_tl.n / g_tl.per, si = g_tl.n % g_tl.per;
if (bi >= g_tl.bufs.size()) {
MTLCounterSampleBufferDescriptor* d = [MTLCounterSampleBufferDescriptor new];
d.counterSet = g_tl.tset;
d.storageMode = MTLStorageModeShared;
d.sampleCount = g_tl.per * 2;
NSError* err = nil;
id<MTLCounterSampleBuffer> b = [dev newCounterSampleBufferWithDescriptor:d error:&err];
if (b == nil) {
g_tl.cap = g_tl.n;
return [cb computeCommandEncoderWithDispatchType:dt];
}
g_tl.bufs.push_back(b);
}
MTLComputePassDescriptor* pd = [MTLComputePassDescriptor computePassDescriptor];
pd.dispatchType = dt;
pd.sampleBufferAttachments[0].sampleBuffer = g_tl.bufs[bi];
pd.sampleBufferAttachments[0].startOfEncoderSampleIndex = 2 * si;
pd.sampleBufferAttachments[0].endOfEncoderSampleIndex = 2 * si + 1;
g_tl.att = true;
return [cb computeCommandEncoderWithDescriptor:pd];
}
void tl_report(id<MTLDevice> dev) {
if (!g_tl.on && g_tl.bufs.empty()) return;
const size_t n = std::min(g_tl.n, g_tl.cap);
if (n < 2) return;
std::vector<MTLCounterResultTimestamp> tsv(2 * n);
for (size_t b = 0; b * g_tl.per < n; ++b) {
const size_t cnt = std::min(g_tl.per, n - b * g_tl.per);
NSData* data = [g_tl.bufs[b] resolveCounterRange:NSMakeRange(0, 2 * cnt)];
if (data == nil) { std::fprintf(stderr, "mtl: DS4_TIMELINE: resolve failed\n"); return; }
std::memcpy(&tsv[2 * b * g_tl.per], [data bytes], 2 * cnt * sizeof(MTLCounterResultTimestamp));
}
const MTLCounterResultTimestamp* ts = tsv.data();
MTLTimestamp c1 = 0, g1 = 0;
[dev sampleTimestamps:&c1 gpuTimestamp:&g1];
mach_timebase_info_data_t tb;
mach_timebase_info(&tb);
const double cpu_ns_per_tick = (double) tb.numer / (double) tb.denom;
(void) cpu_ns_per_tick; // (sampleTimestamps' CPU value is in ns already)
const double ns_per_gpu = (g1 > g_tl.gpu0) ? (double) (c1 - g_tl.cpu0) / (double) (g1 - g_tl.gpu0) : 1.0;
std::fprintf(stderr, "timeline: cap %zu n %zu scale %.6f ns/tick first ts %llu %llu last %llu\n", g_tl.cap, g_tl.n, ns_per_gpu, (unsigned long long) ts[0].timestamp, (unsigned long long) ts[1].timestamp, (unsigned long long) ts[2 * n - 1].timestamp);
size_t from = std::getenv("DS4_TL_FROM") ? (size_t) std::atoll(std::getenv("DS4_TL_FROM")) : 0;
struct Acc { double busy = 0, gap = 0, gap_big = 0; size_t n = 0, n_big = 0; };
std::map<std::string, Acc> acc;
double t_first = 0, t_last = 0, total_busy = 0, total_gap = 0, total_big = 0;
double prev_end = -1, ovl_us = 0;
size_t used = 0, n_ovl = 0;
for (size_t k = from; k < n; ++k) {
if (g_tl.names[k].empty()) continue;
const double st = (double) ts[2 * k].timestamp * ns_per_gpu * 1e-3, en = (double) ts[2 * k + 1].timestamp * ns_per_gpu * 1e-3; // us
if (!(en >= st)) continue;
if (prev_end < 0) t_first = st;
Acc& a = acc[g_tl.names[k]];
a.busy += en - st;
++a.n;
total_busy += en - st;
if (prev_end >= 0 && st < prev_end) { ++n_ovl; ovl_us += prev_end - st; }
if (prev_end >= 0 && st >= prev_end) {
const double gp = st - prev_end;
a.gap += gp;
total_gap += gp;
if (gp > 300.0) { a.gap_big += gp; ++a.n_big; total_big += gp; }
}
prev_end = en;
t_last = en;
++used;
}
std::vector<std::pair<double, std::string>> v;
for (auto& kv : acc) v.emplace_back(kv.second.busy + kv.second.gap, kv.first);
std::sort(v.begin(), v.end(), [](auto& x, auto& y) { return x.first > y.first; });
std::fprintf(stderr, "timeline: %zu dispatches, wall %.1f ms, GPU busy %.1f ms, idle gaps %.1f ms (of it > 300 us: %.1f ms)\n", used,
(t_last - t_first) / 1000.0, total_busy / 1000.0, total_gap / 1000.0, total_big / 1000.0);
std::fprintf(stderr, "timeline: %zu dispatches started before the previous one ended (%.1f ms overlapped)\n", n_ovl, ovl_us / 1000.0);
std::fprintf(stderr, "timeline: kernel n busy ms us/disp gap ms gap/disp big-gap ms (n)\n");
for (size_t i = 0; i < v.size() && i < 40; ++i) {
const Acc& a = acc[v[i].second];
std::fprintf(stderr, "timeline: %-28s %6zu %9.1f %8.1f %8.1f %8.1f %10.1f (%zu)\n", v[i].second.c_str(), a.n, a.busy / 1000.0, a.busy / (double) a.n,
a.gap / 1000.0, a.gap / (double) a.n, a.gap_big / 1000.0, a.n_big);
}
}
} // namespace
extern "C" void ds4_timeline_gate(int open) { g_tl.gate = open != 0; }
// ---- ds4_busy_begin / ds4_busy_end: a one-threadgroup kernel on a second queue that spins on a shared flag while the host works
// (the CPU lane), so the GPU is not idle between two bursts (idle -> the next kernels run 2-3x slower); the host sets the flag the
// moment it is done and the kernel leaves within microseconds. DS4_BUSY=0 turns it off.
namespace {
struct Busy {
bool init = false, ok = false;
id<MTLCommandQueue> q = nil;
id<MTLComputePipelineState> ps = nil;
id<MTLBuffer> flag = nil;
id<MTLCommandBuffer> last = nil;
bool running = false;
};
Busy g_busy;
} // namespace
extern "C" void ds4_busy_begin() {
static const bool off = std::getenv("DS4_BUSY") && std::string(std::getenv("DS4_BUSY")) == "0";
if (off) return;
@autoreleasepool {
if (!g_busy.init) {
g_busy.init = true;
id<MTLDevice> dev = MTLCreateSystemDefaultDevice();
static const char* kSrc = R"MSL(
#include <metal_stdlib>
using namespace metal;
kernel void busy_spin(device volatile atomic_uint* flag [[buffer(0)]], constant uint& cap [[buffer(1)]], uint t [[thread_position_in_grid]]) {
uint x = t;
float f = (float) t * 1e-9f;
for (uint i = 0; i < cap; ++i) {
for (uint j = 0; j < 64; ++j) x = x * 1664525u + 1013904223u;
for (uint j = 0; j < 16; ++j) f = fma(f, 1.0000001f, 1e-7f); // (a real FP chain: keeps the ALU clock up)
x ^= as_type<uint>(f);
if (atomic_load_explicit(flag, memory_order_relaxed) != 0u) break;
}
if (x == 0xdeadbeefu) atomic_store_explicit(flag, 2u, memory_order_relaxed);
}
)MSL";
NSError* err = nil;
id<MTLLibrary> lib = [dev newLibraryWithSource:[NSString stringWithUTF8String:kSrc] options:nil error:&err];
if (lib) g_busy.ps = [dev newComputePipelineStateWithFunction:[lib newFunctionWithName:@"busy_spin"] error:&err];
if (g_busy.ps) {
g_busy.q = [dev newCommandQueue];
g_busy.flag = [dev newBufferWithLength:64 options:MTLResourceStorageModeShared];
g_busy.ok = true;
}
}
if (!g_busy.ok || g_busy.running) return;
g_busy.running = true;
if (g_busy.last != nil) [g_busy.last waitUntilCompleted]; // (the previous spin has left)
*(volatile uint32_t*) [g_busy.flag contents] = 0;
uint32_t cap = std::getenv("DS4_BUSY_CAP") ? (uint32_t) std::atoi(std::getenv("DS4_BUSY_CAP")) : 20000u; // ~ tens of ms at most
// the spin's width: one 256-thread threadgroup leaves most of the GPU idle and the clock drops anyway; DS4_BUSY_W
// threadgroups (256 threads each) keep more of the card's ALUs fed, which is what holds the performance state.
static const uint32_t width = std::getenv("DS4_BUSY_W") ? (uint32_t) std::max(1, std::atoi(std::getenv("DS4_BUSY_W"))) : 1u;
id<MTLCommandBuffer> cb = [g_busy.q commandBuffer];
id<MTLComputeCommandEncoder> enc = [cb computeCommandEncoder];
[enc setComputePipelineState:g_busy.ps];
[enc setBuffer:g_busy.flag offset:0 atIndex:0];
[enc setBytes:&cap length:4 atIndex:1];
[enc dispatchThreads:MTLSizeMake(256 * width, 1, 1) threadsPerThreadgroup:MTLSizeMake(256, 1, 1)];
[enc endEncoding];
[cb commit];
g_busy.last = cb;
}
}
extern "C" void ds4_busy_end() {
if (!g_busy.ok || g_busy.last == nil || !g_busy.running) return;
*(volatile uint32_t*) [g_busy.flag contents] = 1;
g_busy.running = false;
}
#endif
Context::Context() : impl_(new Impl) {
impl_->dev = MTLCreateSystemDefaultDevice();
#if defined(STRATA_DS4_RUNTIME)
if (std::getenv("DS4_TIMELINE") && !g_tl.reg) {
g_tl.on = true;
g_tl.reg = true;
static id<MTLDevice> tl_dev = impl_->dev;
std::atexit([] { tl_report(tl_dev); });
}
#endif
if (!impl_->dev) {
std::fprintf(stderr, "mtl: no Metal device\n");
std::exit(2);
}
impl_->q = [impl_->dev newCommandQueue];
#if defined(STRATA_DS4_RUNTIME)
if (ds4_gpu_residency_set && !std::getenv("DS4_NO_RESIDENCY")) {
id<MTLResidencySet> rs = (__bridge id<MTLResidencySet>)ds4_gpu_residency_set();
if (rs) [impl_->q addResidencySet:rs];
}
#endif
// env, then <exe dir>/metal/ (a component package), then the compiled-in source-tree path
ggml_common_path = strata_metal::resource("STRATA_GGML_COMMON", "ggml-common.h", STRATA_GGML_COMMON_DEFAULT);
kernel_dir = strata_metal::resource("STRATA_METAL_KERNELS", "kernels", STRATA_METAL_KERNELS_DEFAULT);
@@ -105,7 +365,116 @@ int Context::compile(const std::vector<std::string>& files, const std::string& p
return (int)impl_->libs.size() - 1;
}
#if defined(STRATA_DS4_RUNTIME)
// ---- the keep-alive spin (see mtl_rt.h)
static std::atomic<int64_t> g_last_act_ns{0};
static inline int64_t ka_now_ns() { return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count(); }
static std::atomic<bool> g_ka_on{false};
void Context::start_keepalive() {
if (!std::getenv("DS4_KEEPALIVE") || g_ka_on.exchange(true)) return; // (measured slower in the decode loop)
id<MTLDevice> dev = impl_->dev;
std::thread([dev]() {
@autoreleasepool {
static const char* kSpin = R"MSL(
#include <metal_stdlib>
using namespace metal;
kernel void ka_spin(device atomic_uint* c [[buffer(0)]], constant uint& n [[buffer(1)]], uint t [[thread_position_in_grid]]) {
uint x = t;
for (uint i = 0; i < n; ++i) x = x * 1664525u + 1013904223u;
if (x == 0xdeadbeefu) atomic_fetch_add_explicit(c, 1u, memory_order_relaxed);
}
)MSL";
NSError* err = nil;
id<MTLLibrary> lib = [dev newLibraryWithSource:[NSString stringWithUTF8String:kSpin] options:nil error:&err];
if (!lib) return;
id<MTLComputePipelineState> ps = [dev newComputePipelineStateWithFunction:[lib newFunctionWithName:@"ka_spin"] error:&err];
if (!ps) return;
id<MTLCommandQueue> q = [dev newCommandQueue];
id<MTLBuffer> cbuf = [dev newBufferWithLength:64 options:MTLResourceStorageModeShared];
uint32_t iters = std::getenv("DS4_KEEPALIVE_ITERS") ? (uint32_t) std::atoi(std::getenv("DS4_KEEPALIVE_ITERS")) : 20000u;
for (;;) {
if (ka_now_ns() - g_last_act_ns.load(std::memory_order_relaxed) > 50000000LL) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
continue;
}
@autoreleasepool {
id<MTLCommandBuffer> cb = [q commandBuffer];
id<MTLComputeCommandEncoder> enc = [cb computeCommandEncoder];
[enc setComputePipelineState:ps];
[enc setBuffer:cbuf offset:0 atIndex:0];
[enc setBytes:&iters length:4 atIndex:1];
[enc dispatchThreads:MTLSizeMake(256, 1, 1) threadsPerThreadgroup:MTLSizeMake(256, 1, 1)];
[enc endEncoding];
[cb commit];
[cb waitUntilCompleted];
}
}
}
}).detach();
}
// ---- zero-copy weights (see mtl_rt.h)
namespace {
struct ZcBuf { size_t len; void* handle; uint64_t addr; };
std::map<uintptr_t, ZcBuf> g_zc;
std::mutex g_zc_mu;
} // namespace
uint64_t Context::zc_map(const void* p, size_t bytes) {
const size_t pg = 16384;
const uintptr_t lo = (uintptr_t) p & ~(uintptr_t) (pg - 1);
const uintptr_t hi = ((uintptr_t) p + bytes + pg - 1) & ~(uintptr_t) (pg - 1);
std::lock_guard<std::mutex> lk(g_zc_mu);
auto it = g_zc.upper_bound(lo);
if (it != g_zc.begin()) {
--it;
if (it->first <= lo && hi <= it->first + it->second.len) return it->second.addr + ((uintptr_t) p - it->first);
}
static const size_t cap = std::getenv("DS4_ZC_MAX") ? (size_t) std::atoll(std::getenv("DS4_ZC_MAX")) : 9000;
if (g_zc.size() >= cap) return 0;
id<MTLBuffer> b = [impl_->dev newBufferWithBytesNoCopy:(void*) lo length:hi - lo options:MTLResourceStorageModeShared deallocator:nil];
if (!b) return 0;
if (ds4_gpu_residency_set) {
id<MTLResidencySet> rs = (__bridge id<MTLResidencySet>)ds4_gpu_residency_set();
if (rs) {
[rs addAllocation:b];
[rs commit];
}
}
ZcBuf z{hi - lo, (__bridge_retained void*) b, (uint64_t) [b gpuAddress]};
g_zc[lo] = z;
return z.addr + ((uintptr_t) p - lo);
}
size_t Context::zc_count() const { std::lock_guard<std::mutex> lk(g_zc_mu); return g_zc.size(); }
#endif
Pipeline Context::pipeline(int lib, const std::string& fn, const Constants& c) {
#if defined(STRATA_DS4_RUNTIME)
// (cached: the host helpers ask for the same pipeline a dispatch at a time; newComputePipelineState costs ~0.3 ms and
// [MTLDevice name] reads the preferences - the prompt path's expert copies (3 a streamed expert) spent seconds there)
static std::mutex cache_mu;
static std::map<std::string, Pipeline> cache;
std::string key = std::to_string(lib) + "|" + fn;
for (const auto& v : c.vals) key += "|" + std::to_string(v.index) + ":" + std::to_string(v.type) + ":" + std::to_string(v.v);
key += "|" + std::to_string((uintptr_t) this);
{
std::lock_guard<std::mutex> lk(cache_mu);
auto it = cache.find(key);
if (it != cache.end()) return it->second;
}
Pipeline made = pipeline_uncached(lib, fn, c);
{
std::lock_guard<std::mutex> lk(cache_mu);
cache[key] = made;
}
return made;
}
Pipeline Context::pipeline_uncached(int lib, const std::string& fn, const Constants& c) {
#endif
NSError* err = nil;
id<MTLFunction> f;
NSString* name = [NSString stringWithUTF8String:fn.c_str()];
@@ -159,7 +528,11 @@ Pipeline Context::pipeline(int lib, const std::string& fn, const Constants& c) {
p.name += (i ? "," : "") + std::to_string(c.vals[i].index) + "=" + std::to_string((int)c.vals[i].v);
p.name += "}";
}
#if defined(STRATA_DS4_RUNTIME)
if (g_device_name == "unknown GPU") g_device_name = [[impl_->dev name] UTF8String];
#else
g_device_name = [[impl_->dev name] UTF8String];
#endif
return p;
}
@@ -176,6 +549,26 @@ void require_fit(const Pipeline& p, uint32_t threads, const char* where) {
}
Buffer Context::alloc(size_t bytes) {
#if defined(STRATA_DS4_RUNTIME)
// Metal returns the new buffer with one more reference parked in the calling thread's autorelease pool. The engine's host
// threads have no pool (only Context::run opens one), so that reference was never given back: every buffer made here lived for
// the rest of the process, free() or not (a 16 KB job array per expert matvec, ~90K a request: +1.3 GB and a slower queue every
// call, 16 -> 3 tok/s after a few requests on a big Mac).
Buffer r;
@autoreleasepool {
id<MTLBuffer> b = [impl_->dev newBufferWithLength:bytes options:MTLResourceStorageModeShared];
if (!b) {
std::fprintf(stderr, "mtl: alloc %zu failed\n", bytes);
std::exit(4);
}
std::memset([b contents], 0, bytes);
r.handle = (__bridge_retained void*)b;
r.ptr = [b contents];
}
allocated_ += bytes;
r.bytes = bytes;
return r;
#else
id<MTLBuffer> b = [impl_->dev newBufferWithLength:bytes options:MTLResourceStorageModeShared];
if (!b) {
std::fprintf(stderr, "mtl: alloc %zu failed\n", bytes);
@@ -188,6 +581,7 @@ Buffer Context::alloc(size_t bytes) {
r.ptr = [b contents];
r.bytes = bytes;
return r;
#endif
}
Buffer Context::wrap(void* p, size_t bytes, size_t max_span) {
@@ -222,7 +616,47 @@ Buffer Context::wrap(void* p, size_t bytes, size_t max_span) {
return r;
}
#if defined(STRATA_DS4_RUNTIME)
// The runtime's own MTLBuffer under a CPU address (strata::gpu's allocations), when [p, p + bytes) lies inside one. Binding it
// - the same MTLBuffer, so the same GPU address, as every other kernel that touches this memory - instead of a fresh no-copy
// buffer per call. (Each fresh wrapper is a second GPU mapping of the same pages: the quantiser read the activations through
// one mapping while the norm that produced them had written through another, and saw the previous layer's.)
extern "C" void* ds4_gpu_buffer_for_cpu(const void* cpu, size_t bytes, size_t* off) __attribute__((weak_import));
extern "C" size_t ds4_gpu_alloc_remaining(const void* cpu) __attribute__((weak_import));
#endif
Buffer Context::wrap(void* p, size_t bytes) {
#if defined(STRATA_DS4_RUNTIME)
if (ds4_gpu_buffer_for_cpu && !std::getenv("DS4_WRAP_COPY")) {
size_t off = 0;
if (void* mb = ds4_gpu_buffer_for_cpu(p, bytes, &off)) {
Buffer r;
r.handle = (__bridge_retained void*)((__bridge id<MTLBuffer>)mb);
r.ptr = p;
r.bytes = bytes;
r.bias = off;
return r;
}
}
#endif
#if defined(STRATA_DS4_RUNTIME)
if (std::getenv("DS4_WRAP_LOG")) // debugging: a wrap that is not inside one runtime allocation gets a second GPU mapping of the pages
std::fprintf(stderr, "mtl: wrap FALLBACK p=%p bytes=%zu alloc_remaining=%zu\n", p, bytes, ds4_gpu_alloc_remaining ? ds4_gpu_alloc_remaining(p) : (size_t) 0);
#endif
#if defined(STRATA_DS4_RUNTIME)
Buffer r;
@autoreleasepool { // (see alloc: the engine's host threads have no pool of their own)
id<MTLBuffer> b = [impl_->dev newBufferWithBytesNoCopy:p length:bytes options:MTLResourceStorageModeShared deallocator:nil];
if (!b) {
std::fprintf(stderr, "mtl: wrap %zu failed\n", bytes);
std::exit(4);
}
r.handle = (__bridge_retained void*)b;
}
allocated_ += bytes;
r.ptr = p;
r.bytes = bytes;
return r;
#else
id<MTLBuffer> b = [impl_->dev newBufferWithBytesNoCopy:p length:bytes options:MTLResourceStorageModeShared deallocator:nil];
if (!b) {
std::fprintf(stderr, "mtl: wrap %zu failed\n", bytes);
@@ -234,34 +668,75 @@ Buffer Context::wrap(void* p, size_t bytes) {
r.ptr = p;
r.bytes = bytes;
return r;
#endif
}
void Context::free(Buffer& b) {
#if defined(STRATA_DS4_RUNTIME)
// The deferred command buffer is made WITHOUT retained references: a buffer released here while a dispatch that names it
// is still queued is read after it is gone (or after another alloc took its memory: the job arrays of iq_mv read the
// NEXT call's jobs, measured as rows of zeros and different tokens every run). Hold it until the buffer has completed.
std::lock_guard<std::recursive_mutex> lk_(impl_->mu);
const bool hold = impl_->cb_def != nil || impl_->inflight != nil;
#endif
if (b.chunks) {
#if defined(STRATA_DS4_RUNTIME)
for (void* h : *b.chunks) {
if (hold) impl_->graveyard.push_back(h);
else { id<MTLBuffer> x = (__bridge_transfer id<MTLBuffer>)h; x = nil; }
}
#else
for (void* h : *b.chunks) { id<MTLBuffer> x = (__bridge_transfer id<MTLBuffer>)h; x = nil; }
#endif
b = Buffer{};
return;
}
if (b.handle) {
#if defined(STRATA_DS4_RUNTIME)
if (hold) impl_->graveyard.push_back(b.handle);
else { id<MTLBuffer> x = (__bridge_transfer id<MTLBuffer>)b.handle; x = nil; }
#else
id<MTLBuffer> x = (__bridge_transfer id<MTLBuffer>)b.handle;
x = nil;
#endif
}
b = Buffer{};
}
#if defined(STRATA_DS4_RUNTIME)
static std::mutex g_hist_mu;
static std::unordered_map<void*, std::string> g_pipe_names;
static std::unordered_map<std::string, uint64_t> g_kernel_counts;
// DS4_ENC_TIMES: where a dispatch's encoding time goes - the pipeline state, the arguments, the dispatch call
static std::atomic<uint64_t> g_t_pipe{0}, g_t_args{0}, g_t_disp{0};
#endif
static void encode(id<MTLComputeCommandEncoder> enc, const Dispatch& d) {
require_fit(d, "dispatch");
#if defined(STRATA_DS4_RUNTIME)
static const bool enc_times = std::getenv("DS4_ENC_TIMES") != nullptr;
const auto t_a = std::chrono::steady_clock::now();
#endif
[enc setComputePipelineState:P(d.p)];
#if defined(STRATA_DS4_RUNTIME)
const auto t_b = std::chrono::steady_clock::now();
#endif
for (size_t i = 0; i < d.args.size(); ++i) {
const Arg& a = d.args[i];
if (a.buf) {
size_t o;
id<MTLBuffer> mb = BO(*a.buf, a.off, o);
#if defined(STRATA_DS4_RUNTIME)
static const bool chkbuf = std::getenv("DS4_CHECK_BUF") != nullptr;
if (chkbuf && o >= [mb length]) std::fprintf(stderr, "mtl: %s arg %zu: offset %zu past the buffer (%zu bytes; Buffer.bytes %zu bias %zu off %zu)\n", d.p->name.c_str(), i, o, (size_t) [mb length], a.buf->bytes, a.buf->bias, (size_t) a.off);
#endif
[enc setBuffer:mb offset:o atIndex:i];
}
else
[enc setBytes:a.bytes.data() length:a.bytes.size() atIndex:i];
}
#if defined(STRATA_DS4_RUNTIME)
const auto t_c = std::chrono::steady_clock::now();
#endif
if (d.tg_mem) [enc setThreadgroupMemoryLength:(d.tg_mem + 15) / 16 * 16 atIndex:0];
if (d.indirect)
[enc dispatchThreadgroupsWithIndirectBuffer:B(*d.indirect)
@@ -270,16 +745,232 @@ static void encode(id<MTLComputeCommandEncoder> enc, const Dispatch& d) {
else
[enc dispatchThreadgroups:MTLSizeMake(d.grid[0], d.grid[1], d.grid[2])
threadsPerThreadgroup:MTLSizeMake(d.tg[0], d.tg[1], d.tg[2])];
#if defined(STRATA_DS4_RUNTIME)
if (enc_times) {
const auto t_d = std::chrono::steady_clock::now();
g_t_pipe.fetch_add((uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(t_b - t_a).count(), std::memory_order_relaxed);
g_t_args.fetch_add((uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(t_c - t_b).count(), std::memory_order_relaxed);
g_t_disp.fetch_add((uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(t_d - t_c).count(), std::memory_order_relaxed);
}
}
static std::deque<std::string> g_recent; // the last dispatches encoded (printed with a command buffer failure)
static std::string recent_str() { std::string r; for (auto& n : g_recent) r += n + " "; return r; }
static double g_gpu_ms = 0.0;
static std::atomic<uint64_t> g_gpu_busy_us{0}; // DS4_GPU_TOTAL: the GPU time of every committed command buffer
static void add_busy_handler(id<MTLCommandBuffer> cb) {
static const bool on = std::getenv("DS4_GPU_TOTAL") != nullptr;
if (on) [cb addCompletedHandler:^(id<MTLCommandBuffer> c) { const double d = c.GPUEndTime - c.GPUStartTime; if (d > 0) g_gpu_busy_us += (uint64_t) (d * 1e6); }];
} // GPU time of every committed command buffer, summed
static std::atomic<uint64_t> g_wait_ns{0}, g_enc_ns{0}, g_wait_n{0}; // host time: waiting on command buffers; encoding dispatches
static std::mutex g_prof_mu;
static std::unordered_map<std::string, std::pair<double, uint64_t>> g_prof; // DS4_PROF: kernel -> (GPU us, dispatches)
static std::string g_prof_last;
static std::atomic<uint64_t> g_cb_count{0};
static bool g_count_reg = false;
static void ds4_report_counts() {
if (g_gpu_busy_us.load()) std::fprintf(stderr, "mtl: GPU busy (every command buffer) %.1f ms\n", g_gpu_busy_us.load() / 1000.0);
std::fprintf(stderr, "mtl: %.1f ms GPU, %llu dispatches, %llu command buffers\n", g_gpu_ms,
(unsigned long long) g_disp_count.load(), (unsigned long long) g_cb_count.load());
std::fprintf(stderr, "mtl: host waited %.1f ms for the GPU in %llu waits; encoding the dispatches took %.1f ms\n", g_wait_ns.load() / 1e6, (unsigned long long) g_wait_n.load(), g_enc_ns.load() / 1e6);
if (g_t_pipe.load() || g_t_args.load() || g_t_disp.load())
std::fprintf(stderr, "mtl: of the encoding: pipeline %.1f ms, arguments %.1f ms, dispatch %.1f ms\n",
g_t_pipe.load() / 1e6, g_t_args.load() / 1e6, g_t_disp.load() / 1e6);
if (!g_prof.empty()) {
std::vector<std::pair<double, std::string>> pv;
double tot = 0;
for (auto& kv : g_prof) { pv.emplace_back(kv.second.first, kv.first); tot += kv.second.first; }
std::sort(pv.begin(), pv.end(), [](auto& a, auto& b) { return a.first > b.first; });
std::fprintf(stderr, "prof: kernel GPU ms n us/dispatch (total %.1f ms)\n", tot / 1000.0);
for (size_t i = 0; i < pv.size() && i < 30; ++i) {
auto& e = g_prof[pv[i].second];
std::fprintf(stderr, "prof: %-30s %8.1f %6llu %8.1f\n", pv[i].second.c_str(), e.first / 1000.0, (unsigned long long) e.second, e.first / (double) e.second);
}
}
std::vector<std::pair<uint64_t, std::string>> v;
{ std::lock_guard<std::mutex> lk(g_hist_mu); for (auto& kv : g_kernel_counts) v.emplace_back(kv.second, kv.first); }
std::sort(v.begin(), v.end(), [](auto& a, auto& b) { return a.first > b.first; });
for (size_t i = 0; i < v.size() && i < 14; ++i)
std::fprintf(stderr, " %6llu %s\n", (unsigned long long) v[i].first, v[i].second.c_str());
}
extern "C" unsigned long long ds4_dispatch_seq() { return g_disp_count.load(); }
extern "C" void ds4_gpu_sync_all_streams_weak() __asm("_ds4_gpu_sync_all_streams") __attribute__((weak_import));
void Context::split_runs() {
// (the streams' pending copies come first, as in a full flush: they are queued on another MTLCommandQueue and would
// otherwise not have run when the commands committed here read what they write)
if (ds4_gpu_sync_all_streams_weak) ds4_gpu_sync_all_streams_weak();
// Commit the batched dispatches as their own command buffer WITHOUT waiting: the queue runs buffers in order and makes each
// one's writes visible to the next. (Measured: a producer kernel's output was read stale by the next dispatch of the same
// buffer for two kernels in a layer; ending the buffer after them is enough, no wait needed.)
std::lock_guard<std::recursive_mutex> lk_(impl_->mu);
if (impl_->cb_def == nil) return;
++g_cb_count;
[impl_->enc_def endEncoding];
if (impl_->qev == nil) impl_->qev = [impl_->dev newSharedEvent];
[impl_->cb_def encodeSignalEvent:impl_->qev value:++impl_->qev_v];
add_busy_handler(impl_->cb_def);
[impl_->cb_def commit];
impl_->inflight = impl_->cb_def;
impl_->inflight_names = std::move(impl_->def_names); impl_->def_names.clear();
impl_->cb_def = nil;
impl_->enc_def = nil;
impl_->def_n = 0;
}
// Waiting for a committed command buffer: spin on its status for a while (the thread wake of waitUntilCompleted costs
// ~1 ms a wait; the decode token waits ~100 times), then block. DS4_NO_SPIN=1: always block.
static void wait_spin(id<MTLCommandBuffer> cb) {
static const bool no_spin = std::getenv("DS4_NO_SPIN") != nullptr;
static const double budget = std::getenv("DS4_SPIN_US") ? std::atof(std::getenv("DS4_SPIN_US")) * 1e-6 : 20e-3;
if (!no_spin) {
const auto t0 = std::chrono::steady_clock::now();
while (cb.status < MTLCommandBufferStatusCompleted) {
if (std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count() > budget) break;
}
}
if (cb.status < MTLCommandBufferStatusCompleted) [cb waitUntilCompleted];
}
void* Context::mark() {
std::lock_guard<std::recursive_mutex> lk_(impl_->mu);
if (impl_->cb_def != nil) split_runs(); // (the streams' pending work goes first, inside)
if (impl_->inflight == nil) return nullptr;
return (__bridge_retained void*) impl_->inflight;
}
void Context::drop_mark(void* token) {
// The token holds a +1 on a committed command buffer; one nobody waits for is never freed.
if (token) { id<MTLCommandBuffer> cb = (__bridge_transfer id<MTLCommandBuffer>) token; cb = nil; }
}
void Context::wait_mark(void* token) {
id<MTLCommandBuffer> cb = (__bridge_transfer id<MTLCommandBuffer>) token;
const auto tw0 = std::chrono::steady_clock::now();
wait_spin(cb);
g_wait_ns += (uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - tw0).count();
++g_wait_n;
if (cb.status != MTLCommandBufferStatusCompleted)
std::fprintf(stderr, "mtl: deferred command buffer failed: %s\n", [[cb.error localizedDescription] UTF8String]);
if (cb.status != MTLCommandBufferStatusCompleted && std::getenv("DS4_ERRINFO")) std::fprintf(stderr, "mtl: recent: %s\n", recent_str().c_str()), std::fprintf(stderr, "mtl: info: %s\n", [[[cb.error userInfo] description] UTF8String]);
}
void Context::flush_runs() {
if (std::getenv("DS4_TRACE_DISP") && impl_->cb_def != nil) std::fprintf(stderr, "TRACE flush_runs (%d queued)\n", impl_->def_n);
static const bool cb_log = std::getenv("DS4_CB_LOG") != nullptr; // debugging: one line a command buffer (what it held)
if (cb_log && impl_->cb_def != nil) {
std::string first, last;
if (!impl_->def_names.empty()) {
size_t sp = impl_->def_names.find(' ');
first = impl_->def_names.substr(0, sp);
size_t e = impl_->def_names.find_last_not_of(' ');
size_t b = impl_->def_names.find_last_of(' ', e);
last = impl_->def_names.substr(b == std::string::npos ? 0 : b + 1, e - (b == std::string::npos ? 0 : b));
}
std::fprintf(stderr, "CB %d disp first=%-24s last=%s\n", impl_->def_n, first.c_str(), last.c_str());
}
std::lock_guard<std::recursive_mutex> lk_(impl_->mu);
if (!g_count_reg) { g_count_reg = true; std::atexit(ds4_report_counts); }
if (impl_->cb_def != nil) {
++g_cb_count;
[impl_->enc_def endEncoding];
if (impl_->qev == nil) impl_->qev = [impl_->dev newSharedEvent];
[impl_->cb_def encodeSignalEvent:impl_->qev value:++impl_->qev_v];
add_busy_handler(impl_->cb_def);
[impl_->cb_def commit];
impl_->inflight = impl_->cb_def;
impl_->inflight_names = std::move(impl_->def_names); impl_->def_names.clear();
impl_->cb_def = nil;
impl_->enc_def = nil;
impl_->def_n = 0;
}
if (impl_->inflight != nil) {
if (std::getenv("DS4_TRACE_WAIT")) { void* fr[8]; const int nf = backtrace(fr, 8); std::fprintf(stderr, "WAIT flush_runs:\n"); backtrace_symbols_fd(fr + 1, nf - 1, 2); }
const auto tw0 = std::chrono::steady_clock::now();
wait_spin(impl_->inflight);
g_wait_ns += (uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - tw0).count();
++g_wait_n;
{ const double d = impl_->inflight.GPUEndTime - impl_->inflight.GPUStartTime; if (d > 0) g_gpu_ms += d * 1000.0;
if (!g_prof_last.empty()) { std::lock_guard<std::mutex> pl(g_prof_mu); auto& e = g_prof[g_prof_last]; e.first += d * 1e6; e.second += 1; g_prof_last.clear(); } }
if (impl_->inflight.status != MTLCommandBufferStatusCompleted)
std::fprintf(stderr, "mtl: deferred command buffer failed: %s\n", [[impl_->inflight.error localizedDescription] UTF8String]);
if (impl_->inflight.status != MTLCommandBufferStatusCompleted && std::getenv("DS4_ERRINFO")) std::fprintf(stderr, "mtl: failed cb held: %s\n", impl_->inflight_names.c_str());
impl_->inflight = nil;
}
for (void* h : impl_->graveyard) { id<MTLBuffer> x = (__bridge_transfer id<MTLBuffer>)h; x = nil; }
impl_->graveyard.clear();
#endif
}
double Context::run(const std::vector<Dispatch>& ds, int reps, bool concurrent, bool barrier_between) {
#if defined(STRATA_DS4_RUNTIME)
std::lock_guard<std::recursive_mutex> lk_(impl_->mu);
#endif
@autoreleasepool {
#if defined(STRATA_DS4_RUNTIME)
if (ds.size() == 1 && reps == 1 && !barrier_between) {
{ static const std::string skipk = std::getenv("DS4_SKIP_K") ? std::string(",") + std::getenv("DS4_SKIP_K") + "," : std::string(); // debugging: drop these kernels (timing only)
if (!skipk.empty() && skipk.find("," + ds[0].p->name + ",") != std::string::npos) return 0.0; }
if (impl_->cb_def == nil || impl_->def_n >= 2000000) {
if (impl_->cb_def != nil) split_runs(); // (a buffer committed earlier may still be running: the queue keeps the order)
if (std::getenv("DS4_ERRINFO")) {
MTLCommandBufferDescriptor* cd = [MTLCommandBufferDescriptor new];
cd.retainedReferences = NO;
cd.errorOptions = MTLCommandBufferErrorOptionEncoderExecutionStatus;
impl_->cb_def = [impl_->q commandBufferWithDescriptor:cd];
} else
impl_->cb_def = [impl_->q commandBufferWithUnretainedReferences];
// (the wait on our own event is only for a *different* queue: this queue already runs the previous buffer
// before this one, and the wait costs a full GPU round trip at every buffer - 107 buffers a decode token,
// 0.28 ms each. DS4_QEV_WAIT=1 restores it.)
static const bool qev_wait = std::getenv("DS4_NO_QEV_WAIT") == nullptr; // (measured: off is slower - keep it)
if (qev_wait && impl_->qev != nil && impl_->qev_v > 0) [impl_->cb_def encodeWaitForEvent:impl_->qev value:impl_->qev_v];
impl_->enc_def = tl_make_encoder(impl_->dev, impl_->cb_def, std::getenv("DS4_CONCURRENT") ? MTLDispatchTypeConcurrent : MTLDispatchTypeSerial);
}
const auto te0 = std::chrono::steady_clock::now();
encode(impl_->enc_def, ds[0]);
if (g_tl.on && g_tl.att && g_tl.n < g_tl.cap) { g_tl.names[g_tl.n++] = ds[0].p->name; g_tl.att = false; }
g_last_act_ns.store(ka_now_ns(), std::memory_order_relaxed);
g_recent.push_back(ds[0].p->name); if (g_recent.size() > 8) g_recent.pop_front();
if (std::getenv("DS4_ERRINFO")) impl_->def_names += ds[0].p->name + " ";
g_enc_ns += (uint64_t) std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now() - te0).count();
static const bool trace = std::getenv("DS4_TRACE_DISP") != nullptr;
if (trace) { std::lock_guard<std::mutex> lk(g_hist_mu); std::fprintf(stderr, "TRACE disp %s\n", ds[0].p->name.c_str()); }
// Every wrapper binds its own MTLBuffer over the same memory, so Metal's hazard tracking cannot see that one
// dispatch reads what the previous wrote: without an explicit barrier the reader saw the stale value (measured: the
// second matvec's activation quantiser read the previous layer's x).
static const bool no_mb = std::getenv("DS4_NO_MBARRIER") != nullptr;
if (!no_mb) [impl_->enc_def memoryBarrierWithScope:MTLBarrierScopeBuffers];
static const bool enc_each = std::getenv("DS4_ENC_EACH") != nullptr || g_tl.on; // a new encoder per dispatch (debugging, the timeline)
if (enc_each) {
[impl_->enc_def endEncoding];
impl_->enc_def = tl_make_encoder(impl_->dev, impl_->cb_def, (std::getenv("DS4_CONCURRENT") ? MTLDispatchTypeConcurrent : MTLDispatchTypeSerial));
}
++impl_->def_n;
++g_disp_count;
static const bool prof = std::getenv("DS4_PROF") != nullptr; // every dispatch alone, its GPU time kept per kernel
if (prof) { g_prof_last = ds[0].p->name; if (ds4_gpu_sync_all_streams_weak) ds4_gpu_sync_all_streams_weak(); flush_runs(); return 0.0; }
static const bool each = std::getenv("DS4_FLUSH_EACH") != nullptr; // debugging: every dispatch alone, in order
static const std::string only = std::getenv("DS4_FLUSH_AFTER") ? std::getenv("DS4_FLUSH_AFTER") : ""; // ",name1,name2,"
const bool listed = !only.empty() && only.find("," + ds[0].p->name + ",") != std::string::npos;
if (each || listed) {
if (ds4_gpu_sync_all_streams_weak) ds4_gpu_sync_all_streams_weak();
flush_runs();
}
return 0.0;
}
flush_runs();
#endif
id<MTLCommandBuffer> cb = [impl_->q commandBufferWithUnretainedReferences];
id<MTLComputeCommandEncoder> enc =
[cb computeCommandEncoderWithDispatchType:concurrent ? MTLDispatchTypeConcurrent : MTLDispatchTypeSerial];
for (int r = 0; r < reps; ++r)
for (const auto& d : ds) {
encode(enc, d);
#if defined(STRATA_DS4_RUNTIME)
++g_disp_count;
#endif
if (barrier_between) [enc memoryBarrierWithScope:MTLBarrierScopeBuffers];
}
[enc endEncoding];
+133
View File
@@ -0,0 +1,133 @@
// bf16mv_probe.mm - the second dsv41 kernel in MSL: the BF16 matrix-vector product (ds4_gguf.cu:1043's mx_mv_kernel /
// bf16_mv_kernel, single-job case).
//
// CUDA's layout: one warp per row, lane i handles k = i, i + 32, ... in BF16 weights times F32 activations, accumulated in
// F32. This compiles the MSL version at run time, runs it on the GPU and compares against the same arithmetic on the CPU.
// clang++ -O2 -fobjc-arc -framework Metal -framework Foundation -o bf16mv_probe bf16mv_probe.mm && ./bf16mv_probe
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cstring>
#include <vector>
#include <mach/mach_time.h>
static const char* kSrc = R"MSL(
#include <metal_stdlib>
using namespace metal;
// y[r] = sum_k bf16_to_float(w[r*k + kk]) * x[kk] (one warp a row)
kernel void ds4_bf16_mv(device const ushort* w [[buffer(0)]], device const float* x [[buffer(1)]],
device float* y [[buffer(2)]], constant uint& k [[buffer(3)]], constant uint& n [[buffer(4)]],
uint gid [[thread_position_in_grid]], uint lane [[thread_index_in_threadgroup]]) {
const uint rows_per_group = 1u; // one warp covers one row
const uint r = gid / 32u * rows_per_group; // the row this warp owns
if (r >= n) return;
float acc = 0.0f;
for (uint kk = lane; kk < k; kk += 32u) {
const uint bits = uint(w[r * k + kk]) << 16; // BF16 -> F32 is the top half
const float wf = as_type<float>(bits);
acc += wf * x[kk];
}
// warp reduce
acc += simd_shuffle_xor(acc, 16);
acc += simd_shuffle_xor(acc, 8);
acc += simd_shuffle_xor(acc, 4);
acc += simd_shuffle_xor(acc, 2);
acc += simd_shuffle_xor(acc, 1);
if (lane == 0) y[r] = acc;
}
)MSL";
static float bf16_to_f32(unsigned short b) {
unsigned bits = (unsigned) b << 16;
float f;
memcpy(&f, &bits, 4);
return f;
}
static unsigned short f32_to_bf16(float f) {
unsigned u;
memcpy(&u, &f, 4);
return (unsigned short) ((u + 0x7fffu + ((u >> 16) & 1u)) >> 16);
}
int main() {
@autoreleasepool {
const int n = 2048, k = 5120;
std::vector<unsigned short> w((size_t) n * k);
std::vector<float> x(k), y(n);
srand(11);
for (size_t i = 0; i < w.size(); ++i) w[i] = f32_to_bf16(((rand() % 20001) - 10000) / 4096.0f);
for (int i = 0; i < k; ++i) x[i] = ((rand() % 20001) - 10000) / 8192.0f;
id<MTLDevice> dev = MTLCreateSystemDefaultDevice();
NSError* err = nil;
id<MTLLibrary> lib = [dev newLibraryWithSource:[NSString stringWithUTF8String:kSrc] options:nil error:&err];
if (!lib) { printf("MSL compile failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLComputePipelineState> pso = [dev newComputePipelineStateWithFunction:[lib newFunctionWithName:@"ds4_bf16_mv"] error:&err];
if (!pso) { printf("pipeline failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLBuffer> bw = [dev newBufferWithBytes:w.data() length:w.size() * 2 options:MTLResourceStorageModeShared];
id<MTLBuffer> bx = [dev newBufferWithBytes:x.data() length:k * 4 options:MTLResourceStorageModeShared];
id<MTLBuffer> by = [dev newBufferWithLength:n * 4 options:MTLResourceStorageModeShared];
id<MTLCommandQueue> q = [dev newCommandQueue];
id<MTLCommandBuffer> cb = [q commandBuffer];
id<MTLComputeCommandEncoder> ce = [cb computeCommandEncoder];
[ce setComputePipelineState:pso];
[ce setBuffer:bw offset:0 atIndex:0];
[ce setBuffer:bx offset:0 atIndex:1];
[ce setBuffer:by offset:0 atIndex:2];
unsigned uk = k, un = n;
[ce setBytes:&uk length:4 atIndex:3];
[ce setBytes:&un length:4 atIndex:4];
[ce dispatchThreads:MTLSizeMake((NSUInteger) n * 32, 1, 1) threadsPerThreadgroup:MTLSizeMake(32, 1, 1)];
[ce endEncoding];
[cb commit];
[cb waitUntilCompleted];
if (cb.error) { printf("dispatch error: %s\n", [[cb.error localizedDescription] UTF8String]); return 1; }
// throughput: 10 more dispatches, best of them
double best = 1e9;
for (int it = 0; it < 10; ++it) {
id<MTLCommandBuffer> c2 = [q commandBuffer];
id<MTLComputeCommandEncoder> e2 = [c2 computeCommandEncoder];
[e2 setComputePipelineState:pso];
[e2 setBuffer:bw offset:0 atIndex:0];
[e2 setBuffer:bx offset:0 atIndex:1];
[e2 setBuffer:by offset:0 atIndex:2];
[e2 setBytes:&uk length:4 atIndex:3];
[e2 setBytes:&un length:4 atIndex:4];
[e2 dispatchThreads:MTLSizeMake((NSUInteger) n * 32, 1, 1) threadsPerThreadgroup:MTLSizeMake(32, 1, 1)];
[e2 endEncoding];
mach_timebase_info_data_t tb; mach_timebase_info(&tb);
const uint64_t t0 = mach_absolute_time();
[c2 commit];
[c2 waitUntilCompleted];
const double dt = (double) (mach_absolute_time() - t0) * tb.numer / tb.denom / 1e9;
if (dt < best) best = dt;
}
{
const double bytes = (double) n * k * 2.0, flops = 2.0 * n * k;
printf("throughput: %.2f GB/s, %.1f GFLOP/s (%.3f ms for 21.0 MB)\n", bytes / best / 1e9,
flops / best / 1e9, best * 1e3);
}
const float* gy = (const float*) [by contents];
// the standard measure for a dot product: relative L2 over the whole result, not per row (a 5120-term float sum
// cancels, so per-row relative error is ~1e-3 even when the kernels agree to float precision)
double num = 0, den = 0, wrel = 0;
for (int r = 0; r < n; ++r) {
double acc = 0;
for (int kk = 0; kk < k; ++kk) acc += (double) bf16_to_f32(w[(size_t) r * k + kk]) * (double) x[kk];
const double d = (double) gy[r] - acc;
num += d * d;
den += acc * acc;
const double rel = fabs(d) / (fabs(acc) > 1e-9 ? fabs(acc) : 1.0);
wrel = rel > wrel ? rel : wrel;
}
const double l2 = sqrt(num / den);
printf("rows %d k %d: relative L2 %.3e, worst row %.3e\n", n, k, l2, wrel);
printf("%s\n", l2 < 1e-5 ? "PASS" : "FAIL");
}
return 0;
}
+193
View File
@@ -0,0 +1,193 @@
// iq2s_t8_probe.mm - the IQ2_S expert kernel, tiled over the 8-token window (16 GB of the 2.25bpw set).
//
// Block (block_iq2_s, 82 B): f16 d, 32 index bytes, 32 sign bytes, 8 qh bytes, 8 scale bytes. Per group of 32 weights:
// db0 = d*(0.5 + (scales[ib] & 0xf))*0.25, db1 = d*(0.5 + (scales[ib] >> 4))*0.25
// for l in 0..3: idx = qs[4*ib + l] | ((qh[ib] << (8 - 2l)) & 0x300); grid = iq2s_grid[idx];
// sign = signs[4*ib + l]; scale = db[l/2]
// (ggml-quants.c dequantize_row_iq2_s, pinned 3cf03257f; signs live at qs + QK_K/8 in the block.)
// clang++ -O2 -fobjc-arc -framework Metal -framework Foundation -o iq2s_t8_probe iq2s_t8_probe.mm && ./iq2s_t8_probe
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cstring>
#include <cstdint>
#include <fstream>
#include <regex>
#include <string>
#include <vector>
#include <mach/mach_time.h>
static std::vector<uint64_t> table(const std::string& name, size_t expect) {
std::ifstream f("ggml_grids.h");
std::string all((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
std::regex re("constant \\w+ " + name + "\\[[^\\]]*\\] = \\{([^}]*)\\}");
std::smatch m;
if (!std::regex_search(all, m, re)) { fprintf(stderr, "no table %s\n", name.c_str()); exit(2); }
std::vector<uint64_t> out;
const std::string body = m[1];
size_t i = 0;
while (i < body.size() && out.size() < expect) {
while (i < body.size() && !isalnum((unsigned char) body[i])) ++i;
size_t j = i;
while (j < body.size() && (isalnum((unsigned char) body[j]) || body[j] == 'x')) ++j;
if (j > i) out.push_back(strtoull(body.substr(i, j - i).c_str(), nullptr, 0));
i = j;
}
if (out.size() != expect) { fprintf(stderr, "%s: %zu of %zu\n", name.c_str(), out.size(), expect); exit(2); }
return out;
}
int main() {
@autoreleasepool {
const int n = 65536, k = 5120, T = 8;
const int nb = k / 256;
const size_t bs = 82;
auto grid = table("iq2s_grid", 1024);
std::vector<uint8_t> w((size_t) n * nb * bs);
srand(13);
for (size_t i = 0; i < w.size(); ++i) w[i] = (uint8_t) (rand() & 0xff);
for (int r = 0; r < n; ++r)
for (int b = 0; b < nb; ++b) {
_Float16 d = (_Float16) (((rand() % 900) + 100) / 8000.0f);
memcpy(&w[((size_t) r * nb + b) * bs], &d, 2);
}
std::vector<float> x((size_t) T * k), y((size_t) T * n);
for (size_t i = 0; i < x.size(); ++i) x[i] = ((rand() % 20001) - 10000) / 8192.0f;
std::string grids;
{
std::ifstream f("ggml_grids.h");
std::string line;
while (std::getline(f, line)) {
if (line.rfind("#pragma once", 0) == 0 || line.rfind("//", 0) == 0 || line.empty()) continue;
grids += line + "\n";
}
}
std::string src = "#include <metal_stdlib>\nusing namespace metal;\n" + grids + R"MSL(
kernel void iq2s_t8(device const uchar* w [[buffer(0)]], device const float* x [[buffer(1)]],
device float* y [[buffer(2)]], constant uint& k [[buffer(3)]], constant uint& n [[buffer(4)]],
uint gid [[thread_position_in_grid]], uint lane [[thread_index_in_threadgroup]]) {
const uint r = gid / 32u;
if (r >= n) return;
const uint nb = k / 256u;
const device uchar* base = w + (size_t) r * nb * 82u;
float acc[8];
for (int t = 0; t < 8; ++t) acc[t] = 0.0f;
for (uint b = lane; b < nb; b += 32u) {
const device uchar* blk = base + (size_t) b * 82u;
const float d = float(as_type<half>(*(const device ushort*) blk));
const device uchar* qs = blk + 2; // 32 index bytes
const device uchar* sg = qs + 32; // 32 sign bytes
const device uchar* qh = blk + 2 + 64; // 8 qh bytes
const device uchar* sc = blk + 2 + 72; // 8 scale bytes
for (int ib = 0; ib < 8; ++ib) {
const float db0 = d * (0.5f + float(sc[ib] & 0xf)) * 0.25f;
const float db1 = d * (0.5f + float(sc[ib] >> 4)) * 0.25f;
for (int l = 0; l < 4; ++l) {
const uint idx = uint(qs[4 * ib + l]) | ((uint(qh[ib]) << (8 - 2 * l)) & 0x300u);
const ulong g = iq2s_grid[idx & 1023u];
const uint signs = uint(sg[4 * ib + l]);
const float db = (l < 2) ? db0 : db1;
const uint xoff = (uint) ib * 32u + (uint) l * 8u;
float wv[8];
for (int j = 0; j < 8; ++j) {
wv[j] = db * float((g >> (8 * j)) & 0xff) * ((signs & kmask_iq2xs[j]) ? -1.0f : 1.0f);
}
for (uint t = 0; t < 8u; ++t) {
const device float* xt = x + (size_t) t * k + xoff;
float s = 0.0f;
for (int j = 0; j < 8; ++j) s += wv[j] * xt[j];
acc[t] += s;
}
}
}
}
for (uint t = 0; t < 8u; ++t) {
float a = acc[t];
a += simd_shuffle_xor(a, 16);
a += simd_shuffle_xor(a, 8);
a += simd_shuffle_xor(a, 4);
a += simd_shuffle_xor(a, 2);
a += simd_shuffle_xor(a, 1);
if (lane == 0) y[(size_t) t * n + r] = a;
}
}
)MSL";
id<MTLDevice> dev = MTLCreateSystemDefaultDevice();
NSError* err = nil;
id<MTLLibrary> lib = [dev newLibraryWithSource:[NSString stringWithUTF8String:src.c_str()] options:nil error:&err];
if (!lib) { printf("MSL compile failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLComputePipelineState> pso = [dev newComputePipelineStateWithFunction:[lib newFunctionWithName:@"iq2s_t8"] error:&err];
if (!pso) { printf("pipeline failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLBuffer> bw = [dev newBufferWithBytes:w.data() length:w.size() options:MTLResourceStorageModeShared];
id<MTLBuffer> bx = [dev newBufferWithBytes:x.data() length:x.size() * 4 options:MTLResourceStorageModeShared];
id<MTLBuffer> by = [dev newBufferWithLength:y.size() * 4 options:MTLResourceStorageModeShared];
id<MTLCommandQueue> q = [dev newCommandQueue];
unsigned uk = k, un = n;
double best = 1e9;
mach_timebase_info_data_t tb; mach_timebase_info(&tb);
for (int it = 0; it < 5; ++it) {
id<MTLCommandBuffer> cb = [q commandBuffer];
id<MTLComputeCommandEncoder> ce = [cb computeCommandEncoder];
[ce setComputePipelineState:pso];
[ce setBuffer:bw offset:0 atIndex:0];
[ce setBuffer:bx offset:0 atIndex:1];
[ce setBuffer:by offset:0 atIndex:2];
[ce setBytes:&uk length:4 atIndex:3];
[ce setBytes:&un length:4 atIndex:4];
[ce dispatchThreads:MTLSizeMake((NSUInteger) n * 32, 1, 1) threadsPerThreadgroup:MTLSizeMake(32, 1, 1)];
[ce endEncoding];
const uint64_t t0 = mach_absolute_time();
[cb commit];
[cb waitUntilCompleted];
const double dt = (double) (mach_absolute_time() - t0) * tb.numer / tb.denom / 1e9;
if (dt < best) best = dt;
}
printf("IQ2_S window of 8: %.2f GB/s effective (%.2f raw), %.3f ms for %.1f MB\n", 8.0 * w.size() / best / 1e9,
(double) w.size() / best / 1e9, best * 1e3, (double) w.size() / 1e6);
const float* gy = (const float*) [by contents];
const uint8_t* kmask = (const uint8_t*) "\x01\x02\x04\x08\x10\x20\x40\x80";
double num = 0, den = 0;
for (int r = 0; r < n; r += 61) {
for (int t = 0; t < T; ++t) {
double acc = 0;
for (int b = 0; b < nb; ++b) {
const uint8_t* blk = &w[((size_t) r * nb + b) * bs];
_Float16 dh; memcpy(&dh, blk, 2);
const double d = (double) (float) dh;
const uint8_t* qs = blk + 2;
const uint8_t* sg = qs + 32;
const uint8_t* qh = blk + 2 + 64;
const uint8_t* sc = blk + 2 + 72;
for (int ib = 0; ib < 8; ++ib) {
const double db0 = d * (0.5 + (double) (sc[ib] & 0xf)) * 0.25;
const double db1 = d * (0.5 + (double) (sc[ib] >> 4)) * 0.25;
for (int l = 0; l < 4; ++l) {
const uint32_t idx = (uint32_t) qs[4 * ib + l] | (((uint32_t) qh[ib] << (8 - 2 * l)) & 0x300u);
const uint64_t g = grid[idx & 1023u];
const uint32_t signs = sg[4 * ib + l];
const double db = (l < 2) ? db0 : db1;
const int xoff = ib * 32 + l * 8;
for (int j = 0; j < 8; ++j) {
const double wv = db * (double) ((g >> (8 * j)) & 0xff) * ((signs & kmask[j]) ? -1.0 : 1.0);
acc += wv * (double) x[(size_t) t * k + xoff + j];
}
}
}
}
const double diff = (double) gy[(size_t) t * n + r] - acc;
num += diff * diff;
den += acc * acc;
}
}
const double l2 = sqrt(num / den);
printf("relative L2 %.3e (sampled rows)\n", l2);
printf("%s\n", l2 < 1e-5 ? "PASS" : "FAIL");
}
return 0;
}
+190
View File
@@ -0,0 +1,190 @@
// iq2xs_t8_probe.mm - the IQ2_XS expert kernel, tiled over the engine's 8-token window (24 GB of the 2.25bpw set).
//
// Block (block_iq2_xs, 74 B): f16 d, 32 uint16 qs, 8 bytes of scales. Per group of 32 weights:
// db0 = d*(0.5 + (scales[ib] & 0xf))*0.25, db1 = d*(0.5 + (scales[ib] >> 4))*0.25
// for l in 0..3: grid = iq2xs_grid[qs[4ib+l] & 511], signs = ksigns_iq2xs[qs[4ib+l] >> 9], scale = db[l/2]
// (ggml-quants.c dequantize_row_iq2_xs, pinned 3cf03257f.) Decoded once per window, applied to 8 activations.
// clang++ -O2 -fobjc-arc -framework Metal -framework Foundation -o iq2xs_t8_probe iq2xs_t8_probe.mm && ./iq2xs_t8_probe
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cstring>
#include <cstdint>
#include <fstream>
#include <regex>
#include <string>
#include <vector>
#include <mach/mach_time.h>
static std::vector<uint64_t> table(const std::string& name, size_t expect) {
std::ifstream f("ggml_grids.h");
std::string all((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
std::regex re("constant \\w+ " + name + "\\[[^\\]]*\\] = \\{([^}]*)\\}");
std::smatch m;
if (!std::regex_search(all, m, re)) { fprintf(stderr, "no table %s\n", name.c_str()); exit(2); }
std::vector<uint64_t> out;
const std::string body = m[1];
size_t i = 0;
while (i < body.size() && out.size() < expect) {
while (i < body.size() && !isalnum((unsigned char) body[i])) ++i;
size_t j = i;
while (j < body.size() && (isalnum((unsigned char) body[j]) || body[j] == 'x')) ++j;
if (j > i) out.push_back(strtoull(body.substr(i, j - i).c_str(), nullptr, 0));
i = j;
}
if (out.size() != expect) { fprintf(stderr, "%s: %zu of %zu\n", name.c_str(), out.size(), expect); exit(2); }
return out;
}
int main() {
@autoreleasepool {
const int n = 65536, k = 5120, T = 8;
const int nb = k / 256;
const size_t bs = 74;
auto grid = table("iq2xs_grid", 512);
auto signs_t = table("ksigns_iq2xs", 128);
std::vector<uint8_t> w((size_t) n * nb * bs);
srand(9);
for (size_t i = 0; i < w.size(); ++i) w[i] = (uint8_t) (rand() & 0xff);
for (int r = 0; r < n; ++r)
for (int b = 0; b < nb; ++b) {
_Float16 d = (_Float16) (((rand() % 900) + 100) / 8000.0f);
memcpy(&w[((size_t) r * nb + b) * bs], &d, 2);
}
std::vector<float> x((size_t) T * k), y((size_t) T * n);
for (size_t i = 0; i < x.size(); ++i) x[i] = ((rand() % 20001) - 10000) / 8192.0f;
std::string grids;
{
std::ifstream f("ggml_grids.h");
std::string line;
while (std::getline(f, line)) {
if (line.rfind("#pragma once", 0) == 0 || line.rfind("//", 0) == 0 || line.empty()) continue;
grids += line + "\n";
}
}
std::string src = "#include <metal_stdlib>\nusing namespace metal;\n" + grids + R"MSL(
kernel void iq2xs_t8(device const uchar* w [[buffer(0)]], device const float* x [[buffer(1)]],
device float* y [[buffer(2)]], constant uint& k [[buffer(3)]], constant uint& n [[buffer(4)]],
uint gid [[thread_position_in_grid]], uint lane [[thread_index_in_threadgroup]]) {
const uint r = gid / 32u;
if (r >= n) return;
const uint nb = k / 256u;
const device uchar* base = w + (size_t) r * nb * 74u;
float acc[8];
for (int t = 0; t < 8; ++t) acc[t] = 0.0f;
for (uint b = lane; b < nb; b += 32u) {
const device uchar* blk = base + (size_t) b * 74u;
const float d = float(as_type<half>(*(const device ushort*) blk));
const device ushort* qs = (const device ushort*) (blk + 2);
const device uchar* sc = blk + 2 + 64;
for (int ib = 0; ib < 8; ++ib) {
const float db0 = d * (0.5f + float(sc[ib] & 0xf)) * 0.25f;
const float db1 = d * (0.5f + float(sc[ib] >> 4)) * 0.25f;
for (int l = 0; l < 4; ++l) {
const uint q = uint(qs[4 * ib + l]);
const ulong g = iq2xs_grid[q & 511u];
const uint sg = uint(ksigns_iq2xs[q >> 9]);
const float db = (l < 2) ? db0 : db1;
const uint xoff = (uint) ib * 32u + (uint) l * 8u;
float wv[8];
for (int j = 0; j < 8; ++j) {
wv[j] = db * float((g >> (8 * j)) & 0xff) * ((sg & kmask_iq2xs[j]) ? -1.0f : 1.0f);
}
for (uint t = 0; t < 8u; ++t) {
const device float* xt = x + (size_t) t * k + xoff;
float s = 0.0f;
for (int j = 0; j < 8; ++j) s += wv[j] * xt[j];
acc[t] += s;
}
}
}
}
for (uint t = 0; t < 8u; ++t) {
float a = acc[t];
a += simd_shuffle_xor(a, 16);
a += simd_shuffle_xor(a, 8);
a += simd_shuffle_xor(a, 4);
a += simd_shuffle_xor(a, 2);
a += simd_shuffle_xor(a, 1);
if (lane == 0) y[(size_t) t * n + r] = a;
}
}
)MSL";
id<MTLDevice> dev = MTLCreateSystemDefaultDevice();
NSError* err = nil;
id<MTLLibrary> lib = [dev newLibraryWithSource:[NSString stringWithUTF8String:src.c_str()] options:nil error:&err];
if (!lib) { printf("MSL compile failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLComputePipelineState> pso = [dev newComputePipelineStateWithFunction:[lib newFunctionWithName:@"iq2xs_t8"] error:&err];
if (!pso) { printf("pipeline failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLBuffer> bw = [dev newBufferWithBytes:w.data() length:w.size() options:MTLResourceStorageModeShared];
id<MTLBuffer> bx = [dev newBufferWithBytes:x.data() length:x.size() * 4 options:MTLResourceStorageModeShared];
id<MTLBuffer> by = [dev newBufferWithLength:y.size() * 4 options:MTLResourceStorageModeShared];
id<MTLCommandQueue> q = [dev newCommandQueue];
unsigned uk = k, un = n;
double best = 1e9;
mach_timebase_info_data_t tb; mach_timebase_info(&tb);
for (int it = 0; it < 5; ++it) {
id<MTLCommandBuffer> cb = [q commandBuffer];
id<MTLComputeCommandEncoder> ce = [cb computeCommandEncoder];
[ce setComputePipelineState:pso];
[ce setBuffer:bw offset:0 atIndex:0];
[ce setBuffer:bx offset:0 atIndex:1];
[ce setBuffer:by offset:0 atIndex:2];
[ce setBytes:&uk length:4 atIndex:3];
[ce setBytes:&un length:4 atIndex:4];
[ce dispatchThreads:MTLSizeMake((NSUInteger) n * 32, 1, 1) threadsPerThreadgroup:MTLSizeMake(32, 1, 1)];
[ce endEncoding];
const uint64_t t0 = mach_absolute_time();
[cb commit];
[cb waitUntilCompleted];
const double dt = (double) (mach_absolute_time() - t0) * tb.numer / tb.denom / 1e9;
if (dt < best) best = dt;
}
printf("IQ2_XS window of 8: %.2f GB/s effective (%.2f raw), %.3f ms for %.1f MB\n", 8.0 * w.size() / best / 1e9,
(double) w.size() / best / 1e9, best * 1e3, (double) w.size() / 1e6);
const float* gy = (const float*) [by contents];
const uint8_t* kmask = (const uint8_t*) "\x01\x02\x04\x08\x10\x20\x40\x80";
double num = 0, den = 0;
for (int r = 0; r < n; r += 61) {
for (int t = 0; t < T; ++t) {
double acc = 0;
for (int b = 0; b < nb; ++b) {
const uint8_t* blk = &w[((size_t) r * nb + b) * bs];
_Float16 dh; memcpy(&dh, blk, 2);
const double d = (double) (float) dh;
const uint8_t* sc = blk + 2 + 64;
const uint8_t* qb = blk + 2;
for (int ib = 0; ib < 8; ++ib) {
const double db0 = d * (0.5 + (double) (sc[ib] & 0xf)) * 0.25;
const double db1 = d * (0.5 + (double) (sc[ib] >> 4)) * 0.25;
for (int l = 0; l < 4; ++l) {
uint16_t q16;
memcpy(&q16, qb + 2 * (4 * ib + l), 2);
const uint64_t g = grid[q16 & 511u];
const uint32_t sg = (uint32_t) signs_t[(q16 >> 9) & 127u];
const double db = (l < 2) ? db0 : db1;
const int xoff = ib * 32 + l * 8;
for (int j = 0; j < 8; ++j) {
const double wv = db * (double) ((g >> (8 * j)) & 0xff) * ((sg & kmask[j]) ? -1.0 : 1.0);
acc += wv * (double) x[(size_t) t * k + xoff + j];
}
}
}
}
const double diff = (double) gy[(size_t) t * n + r] - acc;
num += diff * diff;
den += acc * acc;
}
}
const double l2 = sqrt(num / den);
printf("relative L2 %.3e (sampled rows)\n", l2);
printf("%s\n", l2 < 1e-5 ? "PASS" : "FAIL");
}
return 0;
}
+185
View File
@@ -0,0 +1,185 @@
// iq2xxs_probe.mm - the IQ2_XXS expert GEMV in MSL, the 2.25bpw set's dominant expert format (49 GB of the 149 GB).
//
// Decode (ggml-quants.c dequantize_row_iq2_xxs, pinned 3cf03257f): a block is 66 bytes - an f16 d and 32 bytes of qs. Eight
// groups of 32 weights: aux32[0..1] are the group's 8 bytes; db = d * (0.5 + (aux32[1] >> 28)) * 0.25; each of the group's four
// sub-blocks takes grid = iq2xxs_grid[aux8[l]] (8 bytes) and signs = ksigns_iq2xs[(aux32[1] >> 7l) & 127], giving
// y[j] = db * grid[j] * (signs & kmask_iq2xs[j] ? -1 : 1).
//
// The grid tables are read from the generated MSL header so the CPU reference uses exactly the same numbers as the GPU.
// clang++ -O2 -fobjc-arc -framework Metal -framework Foundation -o iq2xxs_probe iq2xxs_probe.mm && ./iq2xxs_probe
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#include <cstdio>
#include <cstdlib>
#include <cmath>
#include <cstring>
#include <cstdint>
#include <fstream>
#include <regex>
#include <string>
#include <vector>
#include <mach/mach_time.h>
static std::vector<uint64_t> load_msl_table(const std::string& path, const std::string& name, size_t expect) {
std::ifstream f(path);
std::string all((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
std::regex re("constant \\w+ " + name + "\\[[^\\]]*\\] = \\{([^}]*)\\}");
std::smatch m;
std::vector<uint64_t> out;
if (!std::regex_search(all, m, re)) { fprintf(stderr, "table %s not found\n", name.c_str()); exit(2); }
std::string body = m[1];
size_t i = 0;
while (i < body.size()) {
while (i < body.size() && !isdigit((unsigned char) body[i])) ++i;
if (i >= body.size()) break;
size_t j = i;
while (j < body.size() && (isdigit((unsigned char) body[j]) || body[j] == 'x' || isxdigit((unsigned char) body[j]))) ++j;
out.push_back(strtoull(body.substr(i, j - i).c_str(), nullptr, 0));
i = j;
if (out.size() > expect) break;
}
if (out.size() != expect) { fprintf(stderr, "table %s: %zu of %zu\n", name.c_str(), out.size(), expect); exit(2); }
return out;
}
int main() {
@autoreleasepool {
const int n = 65536, k = 5120; // rows and k
const int nb = k / 256; // IQ2_XXS blocks a row
const size_t bs = 66; // bytes a block
auto grid = load_msl_table("ggml_grids.h", "iq2xxs_grid", 256);
auto signs_t = load_msl_table("ggml_grids.h", "ksigns_iq2xs", 128);
// a synthetic pack of blocks (the decoder does not care where the bytes come from)
std::vector<uint8_t> w((size_t) n * nb * bs);
srand(3);
for (size_t i = 0; i < w.size(); ++i) w[i] = (uint8_t) (rand() & 0xff);
for (int r = 0; r < n; ++r)
for (int b = 0; b < nb; ++b) {
// a sane f16 scale in the block's first two bytes
_Float16 d = (_Float16) (((rand() % 900) + 100) / 8000.0f);
memcpy(&w[((size_t) r * nb + b) * bs], &d, 2);
}
std::vector<float> x(k), y(n);
for (int i = 0; i < k; ++i) x[i] = ((rand() % 20001) - 10000) / 8192.0f;
// pull the grid table into the MSL by textual include at compile time
std::string grids;
{
std::ifstream f("ggml_grids.h");
std::string line;
while (std::getline(f, line)) {
if (line.rfind("#pragma once", 0) == 0 || line.rfind("//", 0) == 0 || line.empty()) continue;
grids += line + "\n";
}
}
std::string src = "#include <metal_stdlib>\nusing namespace metal;\n" + grids + R"MSL(
kernel void iq2xxs_gemv(device const uchar* w [[buffer(0)]], device const float* x [[buffer(1)]],
device float* y [[buffer(2)]], constant uint& k [[buffer(3)]], constant uint& n [[buffer(4)]],
uint gid [[thread_position_in_grid]], uint lane [[thread_index_in_threadgroup]]) {
const uint r = gid / 32u;
if (r >= n) return;
const uint nb = k / 256u;
const device uchar* base = w + (size_t) r * nb * 66u;
float acc = 0.0f;
for (uint b = lane; b < nb; b += 32u) {
const device uchar* blk = base + (size_t) b * 66u;
const float d = float(as_type<half>(*(const device ushort*) blk));
for (int ib = 0; ib < 8; ++ib) {
// groups advance 4 bytes and read 8, so an 8-byte vector load is misaligned on odd groups: read bytes
const uint aux0 = uint(blk[2 + 4 * ib]) | (uint(blk[3 + 4 * ib]) << 8) | (uint(blk[4 + 4 * ib]) << 16) | (uint(blk[5 + 4 * ib]) << 24);
const uint aux1 = uint(blk[6 + 4 * ib]) | (uint(blk[7 + 4 * ib]) << 8) | (uint(blk[8 + 4 * ib]) << 16) | (uint(blk[9 + 4 * ib]) << 24);
const float db = d * (0.5f + float(aux1 >> 28)) * 0.25f;
for (int l = 0; l < 4; ++l) {
const ulong g = iq2xxs_grid[(aux0 >> (8 * l)) & 0xff];
const uint sg = uint(ksigns_iq2xs[(aux1 >> (7 * l)) & 127]);
const uint xoff = (uint) ib * 32u + (uint) l * 8u;
for (int j = 0; j < 8; ++j) {
const float wv = db * float((g >> (8 * j)) & 0xff) * ((sg & kmask_iq2xs[j]) ? -1.0f : 1.0f);
acc += wv * x[xoff + j];
}
}
}
}
acc += simd_shuffle_xor(acc, 16);
acc += simd_shuffle_xor(acc, 8);
acc += simd_shuffle_xor(acc, 4);
acc += simd_shuffle_xor(acc, 2);
acc += simd_shuffle_xor(acc, 1);
if (lane == 0) y[r] = acc;
}
)MSL";
const uint8_t kmask[8] = {1, 2, 4, 8, 16, 32, 64, 128};
// kmask_iq2xs comes from ggml_grids.h
std::string src_with_km = src;
id<MTLDevice> dev = MTLCreateSystemDefaultDevice();
NSError* err = nil;
id<MTLLibrary> lib = [dev newLibraryWithSource:[NSString stringWithUTF8String:src_with_km.c_str()] options:nil error:&err];
if (!lib) { printf("MSL compile failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLComputePipelineState> pso = [dev newComputePipelineStateWithFunction:[lib newFunctionWithName:@"iq2xxs_gemv"] error:&err];
if (!pso) { printf("pipeline failed: %s\n", [[err localizedDescription] UTF8String]); return 1; }
id<MTLBuffer> bw = [dev newBufferWithBytes:w.data() length:w.size() options:MTLResourceStorageModeShared];
id<MTLBuffer> bx = [dev newBufferWithBytes:x.data() length:k * 4 options:MTLResourceStorageModeShared];
id<MTLBuffer> by = [dev newBufferWithLength:n * 4 options:MTLResourceStorageModeShared];
id<MTLCommandQueue> q = [dev newCommandQueue];
unsigned uk = k, un = n;
double best = 1e9;
mach_timebase_info_data_t tb; mach_timebase_info(&tb);
for (int it = 0; it < 5; ++it) {
id<MTLCommandBuffer> cb = [q commandBuffer];
id<MTLComputeCommandEncoder> ce = [cb computeCommandEncoder];
[ce setComputePipelineState:pso];
[ce setBuffer:bw offset:0 atIndex:0];
[ce setBuffer:bx offset:0 atIndex:1];
[ce setBuffer:by offset:0 atIndex:2];
[ce setBytes:&uk length:4 atIndex:3];
[ce setBytes:&un length:4 atIndex:4];
[ce dispatchThreads:MTLSizeMake((NSUInteger) n * 32, 1, 1) threadsPerThreadgroup:MTLSizeMake(32, 1, 1)];
[ce endEncoding];
const uint64_t t0 = mach_absolute_time();
[cb commit];
[cb waitUntilCompleted];
const double dt = (double) (mach_absolute_time() - t0) * tb.numer / tb.denom / 1e9;
if (dt < best) best = dt;
}
printf("throughput: %.2f GB/s (%.3f ms for %.1f MB)\n", (double) w.size() / best / 1e9, best * 1e3,
(double) w.size() / 1e6);
// the CPU reference: the same decode, from the same tables
const float* gy = (const float*) [by contents];
double num = 0, den = 0;
for (int r = 0; r < n; ++r) {
double acc = 0;
for (int b = 0; b < nb; ++b) {
const uint8_t* blk = &w[((size_t) r * nb + b) * bs];
_Float16 dh;
memcpy(&dh, blk, 2);
const double d = (double) (float) dh;
for (int ib = 0; ib < 8; ++ib) {
uint32_t aux[2];
memcpy(aux, blk + 2 + 4 * ib, 8);
const double db = d * (0.5 + (double) (aux[1] >> 28)) * 0.25;
const uint8_t* a8 = (const uint8_t*) aux;
for (int l = 0; l < 4; ++l) {
const uint64_t g = grid[a8[l]];
const uint32_t sg = (uint32_t) signs_t[(aux[1] >> (7 * l)) & 127];
const int xoff = ib * 32 + l * 8;
for (int j = 0; j < 8; ++j) {
const double wv = db * (double) ((g >> (8 * j)) & 0xff) * ((sg & kmask[j]) ? -1.0 : 1.0);
acc += wv * (double) x[xoff + j];
}
}
}
}
const double diff = (double) gy[r] - acc;
num += diff * diff;
den += acc * acc;
}
const double l2 = sqrt(num / den);
printf("relative L2 %.3e\n", l2);
printf("%s\n", l2 < 1e-4 ? "PASS" : "FAIL");
}
return 0;
}
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff. Show more