Skip to content

[Engine] sm_80 (GA100) tuning from #3712, all five features in one PR: one commit each, to split or cherry-pick as you prefer - #3779

Open
linxuhao wants to merge 19 commits into
Niko1221:mainfrom
linxuhao:sm80-all
Open

linxuhao wants to merge 19 commits into
Niko1221:mainfrom
linxuhao:sm80-all

Conversation

@linxuhao

@linxuhao linxuhao commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Problem and result

sm_80 has no entry in the engine's per-card tables, so on a GA100 card most of the prompt and decode path runs on choices made for other architectures. #3712 has the measurements from a CMP 170HX (GA100, sm_80, 70 SMs, PCIe Gen2 x4) running Qwen3.8-Flash-Next IQ3_S with every expert resident. On our working branch, with these features and the switches turned on, prompts run up to 2.0x the speed of stock 0.2.1.4, decode +20 %, and conversation park/restore 5-10x faster.

This PR puts all of it in front of you in one place, rebased on current main. It is not meant to be merged as it is. Each feature is its own commit (or a few consecutive ones), so you can cherry-pick, or ask us to split it into the five thematic PRs listed in #3712, or into finer ones.

Related discussion

#3712 (scope proposal; no agreement yet). Also #3252 (A100 slowdowns, where sm_80 also falls back to the shared MMVQ-IL table) and #3453 (the sm_89 MMVQ-IL table, which touches the same table list).

Approach

Every switch is opt-in. With all of them unset, the default path is byte-identical to main on every card, with two exceptions on sm_80 only. Both exceptions are bitwise: the output stays identical.

commits feature default
48dc535 wide dense dequant also on sm_80 (as on sm_61 / sm_86; STRATA_DEQUANT_WIDE=0 turns it off) on, sm_80 only, bitwise
eaedca8 MMVQ-IL sm_80 row tables (STRATA_MMVQ_IL_SM80=0 restores the shared table) on, sm_80 only, bitwise
056d36a STRATA_PF_FUSED_NT=256 256-thread fused IQ prompt experts, STRATA_PF_BATCH=N whole-layer launches (WideBatch) off
da81826, 0ba47e6 STRATA_PLE_COMPACT=1 raw IQ4_NL PLE rows dequantized on the device; STRATA_PLE_COPY_STREAM=1 their upload on its own stream off
952dd78 STRATA_EMBED_VRAM=1 token-embedding table in VRAM (only while 512 MiB stay free) off
741fc6a STRATA_DEQUANT_SIDE=1 dense dequant on a side stream off
579cbfb STRATA_PF_FUSED_F16=1 / STRATA_PF_FUSED_WS=1 f16 mma fused experts (rounding-level) off
f1cabc9 STRATA_GDN_TC=1 DeltaNet prompt recurrence as a chunked tensor-core scan (rounding-level; sm_80+, 48+ SMs) off
0d51292 STRATA_CKPT_ASYNC=1, STRATA_CKPT_VRAM_SLOTS=N conversation checkpoints off
3e0c141, 56088e7 STRATA_PARK_ASYNC=1, STRATA_CKPT_POOL=N, STRATA_CKPT_GATHER=1 background park, ready checkpoint pool off
bafca41 STRATA_PARK_VRAM_MIB=N parked K/V in VRAM off
a04f439 docs for the conversation-cache switches -
68c487c STRATA_GRAPH_PRIO=1 verify-window graph nodes keep their stream priority off
1f4b813 mmvq_il_parity harness: L2-residency arms and clock warm-up (harness only) -
116992a STRATA_MMVQ_IL_PF=1 hoisted-load, exact-integer MMVQ-IL kernels for Q6_K off
6d6f0f3 docs for the decode switches -

Compatibility effects:

Validation

Passed:

Failed: none observed in builds. During the GPU runs, in 3 of about 35 engine starts the engine stopped answering after a park with evictions, each time on upstream's conversation-cache path (the PR 4 switches off). It did not reproduce in 15 reruns of the same sequences (5 greedy runs, 10 full benchmark runs on the stock build); details are in the results comment.

Not run:

  • SYCL and Metal builds; HIP execution (no AMD card); CUDA 12 builds; other sm_80 cards.
  • The gfx1100 + STRATA_PREFILL_MMQ build and the multi-architecture object build of the fused-expert files ran before the rebase onto 011c9e6 and were not repeated after it. Upstream did not change those files.

Limits

  • Measured on one CMP 170HX, which is not an A100: 70 SMs, HBM2e and a Gen2 x4 link. The sm_80 table rows may want re-measuring on an A100.
  • The first restore of a run with parked K/V in VRAM (41.6 / 56.9 ms) is slower than the later ones (6.5-12.6 ms). We have not found why.

🤖 Generated with Claude Code

linxuhao and others added 18 commits October 22, 2026 20:52
Measured on a CMP 170HX (GA100, 70 SMs): the prompt path dequantizes the dense Q4_K/Q5_K/Q6_K/IQ4_XS weights every
chunk. With the wide kernels (STRATA_DEQUANT_WIDE=1, the sm_61 / sm_86 default): GPU time of a 3,259-token prompt
857 -> 792 ms; prompts 3.3K +6.3..+7.2%, 13K +4.7%, 52K +3.9%; greedy output identical (11 of 11 prompts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read off mmvq_il_parity --bench --emit-table (the same 3% rule), bitwise equal to native_mmvq. Decode +2.9% on the
CMP 170HX (Qwen3.8-Flash-Next IQ3_S).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…6), whole-layer launches (STRATA_PF_BATCH=N), two-pass weight loads in the 256-thread kernels (bitwise)

Measured on a CMP 170HX (GA100, 70 SMs), one MoE layer of 3,256 tokens (prefill_fused_iq_test, IQ3_S/IQ4_NL):
- STRATA_PF_FUSED_NT=256: 256 threads, 2 blocks an SM, bitwise: 8.27 ms
- two-pass weight loads: the weight loads of the next k step are issued before the current step's decode, bitwise.
  Measured on the 512-thread kernel: 10.09 -> 9.33 ms.  Here they run only in the NT = 256 kernels (TWO_PASS), so
  the default 512-thread kernels keep their load sequence on every card.
- STRATA_PF_BATCH=N: experts per launch, one persistent grid a layer.  Above kMaxBatch (128) only on the NT = 256
  kernels, which take a WideBatch (kBatchRoom = 512 pointers, 4 KB of kernel parameters: CUDA 12.1+, Volta and
  newer); kBatchRoom is kMaxBatch on HIP, gfx906 and STRATA_EXPERIMENTAL_SM60.

All opt-in.  Batch, the default kernels (native_kernel WW 2 / 4 at 512 threads, moe_fused.cu, moe_f16_kq.cu) and
their launches are unchanged; the NT = 256 instances are new device code in every CUDA build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uantizes them on the device

1,440 bytes a token over PCIe instead of 10,240 floats' bytes; the same floats as the host dequant (bitwise).
On a PCIe Gen2 x4 card (CMP 170HX) the cmp_bench3 prompts (4K and 16K) were +1.0..+1.7 % faster. Opt-in.

- the raw-row buffer's cudaFree and the error clear after a failed cudaMalloc are (void): HIP's hipError_t is
  [[nodiscard]]
- metal: ple_iq4nl_dequant is a stub (stubs_0145.cpp); the Metal prompt runs through its window and never reaches it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir own stream

The STRATA_PLE_COMPACT upload sat on the compute stream before layer 1; it now goes on a non-blocking stream with
events for the dequant and the host buffer's reuse. Same bytes. The upload stream's synchronize / destroy and its
event's destroy are (void) (HIP's [[nodiscard]] hipError_t).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The table lived in mapped pinned host memory, so every prompt's row gather crossed the link. With the env, when
512 MiB stay free beside it, the table goes to VRAM (322 MiB for an IQ4_XS table) through the calls the existing
cannot-pin path uses; same bytes, same gather kernel. The start-up line says where the table is ("in VRAM" also for
the existing VRAM fallbacks, which it called mapped host memory). The failed copy's cudaFree and the error clear are
(void) (HIP's [[nodiscard]] hipError_t).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e stream into a double-buffered scratch

Gemm::native ran the dequant serially in front of every dense GEMM on the compute stream. With the env the dequant
of the next projection runs on a side stream into the other scratch (64 MiB more of the prompt path's region), with
ready/free events; the sliced path stays serial. Same kernels, same bits. Off on HIP.

- the side stream's event waits / records and ~Gemm's destroys are (void) (HIP's [[nodiscard]] hipError_t)
- metal: gemm_dequant_side() answers false and Gemm::set_dequant_side(nullptr) the serial form (stubs_0145.cpp)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…16=1) and a warp-specialized kernel (STRATA_PF_FUSED_WS=1)

Measured on a CMP 170HX (GA100, 70 SMs), one MoE layer of 3,256 tokens (prefill_fused_iq_test, IQ3_S/IQ4_NL),
against the NT = 256 kernels' 8.27 ms:
- STRATA_PF_FUSED_F16=1: f16 mma with the block scales folded into the weights and activations (error vs the FP32
  reference = MMQ's): 7.90 ms.  It runs in the NT = 256 kernels (it implies STRATA_PF_FUSED_NT=256).
- STRATA_PF_FUSED_WS=1: producer warps decode into an f16 ring, consumer warps run the mma (named barriers),
  bitwise = the f16 path: 7.65 ms.  Compile-time knobs STRATA_WS_PROD / _SLOTS / _NB / _KS / _HTAB (defaults
  256 / 2 / 2 / 2 / 0; _HTAB=1 or 2 turns on the IQ3 decode table).

Both opt-in and rounding-level; unset = the kernels as before.  Both take a WideBatch, as the NT = 256 kernels do:
the F16 kernels are NT = 256 instances, and native_wide() / experts_native(const WideBatch&) also cover the WS
kernel (one block a multiprocessor), so STRATA_PF_BATCH above kMaxBatch reaches it; Batch and the default kernels'
launches are unchanged.  native_ws_kernel and the F16 path are CUDA only: the WideBatch launch()'s WS and F16 arms
(WS_THREADS, ws_smem_bytes, f16c_requested: all defined under !__HIPCC__) sit under !__HIPCC__; d.ws is never set
on HIP.  The two-pass weight loads stay in the NT = 256 kernels, the F16 ones included.  The F16 path is sm_80+:
the sm_75 pass (STRATA_PF_FUSED_SM75) compiles its native_kernel instances against __trap() stubs of mma16h and
i8x4_h2 (dev_info stops before the NT = 256 setup on Turing, so they never run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d scan on the tensor cores (sm_80+, opt-in)

The chunked WY-form recurrence with every product on the tensor cores, for sm_80+ cards with 48+ SMs and 122 KB of
shared memory a block.  STRATA_GDN_TC=1 dispatches it before the STRATA_GDN_CHUNKED branch for prompt chunks of 128+
tokens (kGdnTcMin); a card below that, or a failed scratch allocation, takes the kernels as before.  Unset = the
kernels as before.  Rounding-level: the output is not the FP32 kernels' bits.  CUDA only (gdn_tc.cuh is under
!__HIPCC__; the dispatch sits in kernels.cu's CUDA branch).

src/prefill/gdn_tc.cuh (new): the mma.sync / ldmatrix wrappers, the f16 hi/lo split with power-of-two scaling, the
dynamic scale exponent, the per-chunk products a warp runs and the two kernels:
- gdn_tc_prep_kernel: gdn_chunk_prep_kernel's arithmetic, plus the k and q f16 hi / lo images x 2^8 (built in shared
  memory from the float4 registers the rows arrive in), the P images x 2^8, T as f16 hi / lo images x 2^et (et from
  the warp's largest |T|) and the token scalars (beta, exp(gamma), exp(gL - gamma), gamma, the scale 2^-et) as a
  float4 a token, into gdn_tc_scratch (60.3 MB a device).  Its Gram products k_t.k_i and q_t.k_i run as f16x3 mma
  (48 mma and 32 ldmatrix a warp in place of ~1,150 LDS a thread); after them, warps VPK + j take P and the scalars
  of value head j while warps j build A and solve with lane i's column of T in registers.
- gdn_tc_scan_kernel: a block per value head (48 blocks of 256 threads, one wave); warp w holds value columns
  16w..16w+15 of the transposed state (16 m16n8 f32 accumulator tiles) across every chunk of a super-block.
  X^T / QS^T = S^T K^T / Q^T on f16x3 (the state x 2^es), D^T = Y^T T^T on f16x3 from the prep's T images (6 of 8
  tiles, Y^T x 2^ey), o^T = e QS^T + D^T P^T on f16x3 with P's triangle skipped, S^T = eL S^T + Dd^T K on f16x3
  through ldmatrix.trans: 324 mma a warp a chunk, one __syncthreads a chunk.  Each chunk is staged with 15 cp.async
  a thread right after the chunk barrier from the prep's images.  The warp maxima run through redux.sync over short
  chains (eight chains of eight, four of four); the warp maximum and the shuffles use the full mask (a lane-divergent
  L2 prefetch before the first maximum of a chunk made __activemask() unsafe; the kernel has no early return, so
  the full mask adds no reconvergence code).  255 registers, 0 spills (prep 52).
The header is included inside the anonymous namespace of kernels.cu and of the harness, so the harness runs the
engine's text verbatim.

src/prefill/gdn_rec_parity.cu: check_variant (any launcher of gdn_rec_chunked's shape), the tc variant at
T = 1..4099, 8192, 32768 and six stress inputs (state x 0.01 / x 50 / 0, v x 50, slow gates, coherent keys) at 4099
and 32768 against gdn_rec_kh_kernel (gate 1e-4); --selftest-frag (each helper on one warp against a double
reference); --bench-tc (the tensor-core series alone, for A / B runs of two builds); --long (T = 51,820).

Measured on a CMP 170HX (GA100, 70 SMs):
- gdn_rec_parity: output <= 2.3e-6, state <= 2.3e-6 against gdn_rec_kh_kernel over every T and stress case (the
  coherent-keys case 1.8e-6 / 8.5e-7); --selftest-frag (b) 9.1e-7, (c) 4.2e-7, (d) 3.3e-7, (e) 0 words differ.
  (--long T = 51,820 at the first cooperative-staging kernel: output 1.17e-06, state 2.58e-07.)
- --bench-tc (two builds back to back, twice, medians of alternating runs), us a token at 2048 / 8192 / 32768:
  v0 0.288 / 0.287 / 0.287 (2.79x gdn_rec_kh_kernel, 0.80 us); redux maxima 0.282 / 0.281 / 0.281 (2.85x);
  full mask 0.283 / 0.282 / 0.282 (gdn_rec_kh_kernel 1.65 / 6.56 / 26.23 ms); cp.async staging from the prep's
  images 0.262 / 0.260 / 0.260 (3.07x); short max chains 0.261 / 0.260 / 0.259; the prep's Gram products on the
  tensor cores 0.238 / 0.238 / 0.238 (3.37x; prep 233 -> 153 us a 2048-token super-block under ncu); the prep's
  serial phase on six warps 0.219 / 0.218 / 0.218 (3.67x; prep 153 -> 95 us); D^T on f16x3 0.214 / 0.214 / 0.214
  (3.75x).
- Engine (Qwen3.8-Flash-Next IQ3_S, STRATA_PREFILL_TIMING), with the first engine kernel: the "gdn recurrence"
  phase of a 3,259-token prompt 105 -> 43 ms, of a 51,820-token prompt 1,705 -> 686 ms; prompts 3,849 -> 4,104
  tok/s (3K), 4,163 -> 4,439 (13K), 4,060 -> 4,332 (52K); the 11-prompt greedy set identical to the FP32 kernels.

Measured and not kept:
- stager warps with named barriers (two more warps staging a chunk ahead, 320 threads): 0.299 / 0.297 / 0.297 us a
  token against 0.282 / 0.281 / 0.281 (6 % slower): a block of more than 8 warps is capped at 168 registers a
  thread on GA100 and the consumers spill.
- the output norm fused into the scan: correct (y16 within 4.4e-4 of its largest value against
  gdn_out_norm_kernel), but 0.303 against 0.214 + 0.057 us a token at 32768 (the chunk loop ~2,010 -> ~2,900
  instructions at the 255-register cap).
- the prep overlapped with the previous super-block's scan on low / high priority streams joined by events (a
  two-slot scratch): it wins in the harness (0.214 -> 0.215 / 0.191 / 0.184 us a token at 2048 / 8192 / 32768) but
  loses in the engine: a 3,259-token prompt's GPU timeline 722 -> 760 ms, a 51,820-token one 11.79 -> 12.11 s, the
  "gdn recurrence" phase 34 -> 37 ms, from the cross-stream scheduling gaps.  Both launches stay on the caller's
  stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t copy) and STRATA_CKPT_VRAM_SLOTS (recent GDN states kept in VRAM)

- STRATA_CKPT_ASYNC=1: a turn checkpoint's GDN state is copied device-to-device into a VRAM stage, then a background
  thread copies it in 4 MiB pieces into a pinned buffer and into the checkpoint; every reader joins first (selective
  joins: only the copy it needs). Turn checkpoint 72 -> 1 ms on a PCIe Gen2 x4 card (118 MB of GDN state at 1.64 GB/s).
- STRATA_CKPT_VRAM_SLOTS=N: the last N saved GDN states stay in VRAM (tag-checked; only allocated while 1.5 GB stays
  free). Restoring a parked conversation 116 -> 28-47 ms.
- STRATA_TRACE lines carry a steady-clock timestamp.

Both opt-in; outputs bitwise identical (conversation A/B/A check).

HIP: ckpt_async_on() is false under STRATA_HIP_GFX906 as well, and the runtime calls whose result the cache does not
check are cast to (void) (the HIP shims declare hipError_t [[nodiscard]]), so this commit carries its own HIP guard
and builds alone for gfx1100 and gfx906.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd and moves the dropped checkpoints

Parking the outgoing conversation copied its K/V device to host and deep-copied its checkpoints before the next
prompt could start: 213 ms at a 3K context, 334 ms at 13K on a PCIe Gen2 x4 card (CMP 170HX).

- STRATA_PARK_ASYNC=1 (CUDA, opt-in): conversation_kv_save copies the device K/V device to device into a VRAM stage
  (only while 1.5 GB stays free beside it) and a background thread moves the stage into the host buffers (4 MiB
  pieces through two pinned buffers).  conversation_park_join waits for it: every reader of a parked image's K/V,
  the cache before it frees an entry, and the image itself (SavedConversation::pending) before its buffers go.
  ConversationBuffer::resize_for_overwrite leaves the added bytes unfilled for that copy.
- With it, the checkpoints this request drops right after the park (all of them when another state comes in, else
  the ones off this prompt's path) move into the parked image instead of being copied, and a background allocation
  refills STRATA_CKPT_POOL for the next save.
- conversation_snapshot_save takes dropped checkpoints' storage (STRATA_CKPT_POOL) for its copies.

Park 213 -> 64-75 ms (3K), 334 -> 68 ms (13K); outputs identical (greedy 11 of 11, the A/B/A conversation check).

HIP: park_async_on() is false under STRATA_HIP_GFX906 as well, on_device() returns false in the shims (none maps
cudaPointerGetAttributes; the slot then takes no part, as when the query fails), and the runtime calls whose result
the cache does not check are cast to (void), so this commit carries its own HIP guard and builds alone for gfx1100
and gfx906.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…copies gathered, helpers off the host core, park traces

Measured on a CMP 170HX (PCIe Gen2 x4): a conversation switch's park still paid 38-52 ms for the live state - the
STRATA_CKPT_POOL pool was empty at every park (a switch takes two checkpoints and the refill added one), so the
118 MB GDN vector was allocated fresh and page-faulted. Now:
- STRATA_CKPT_POOL=N: the pool keeps N (default 2) ready checkpoints, pre-sized to the session's state sizes, topped
  up on a background thread after any take.
- STRATA_CKPT_GATHER=1: the 37 small synchronous device-to-host copies of a checkpoint (ple, tails, dead, block_pos)
  gathered device-to-device into one buffer and fetched with one copy through a pinned buffer.
- The STRATA_PARK_ASYNC stage is allocated with 64 MiB of headroom (no cudaFree/cudaMalloc as the conversation
  grows) and the pointer-attribute query is skipped for a VRAM-resident K/V.
- The three background helpers (pool top-up, the checkpoint DMA, the park DMA) leave the host's pinned core.
- A parked image whose live GDN state is still being copied joins before its buffers are freed.
- STRATA_PARK_TRACE prints the joins, the live-state vectors' readiness and the K/V sub-timers; the d2d time is
  measured right.
Park at a 3K context 64-75 -> 21-40 ms; outputs identical (greedy 11 of 11, the A/B/A conversation check).

HIP: ckpt_gather_on() is false under STRATA_HIP_GFX906 as well, and the runtime calls whose result the cache does
not check are cast to (void), so this commit carries its own HIP guard and builds alone for gfx1100 and gfx906.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…' K/V in VRAM in front of the host copy

With STRATA_PARK_ASYNC, a park copies the K/V device-to-device into a VRAM slot (64 MiB granules, up to N MiB in all,
only while 1.5 GB stays free) that the background DMA reads from; the host image is still filled. A switch back
restores the K/V device-to-device from the slot (~1 ms instead of 27-107 ms over a PCIe Gen2 x4 link) and needs no
join on the DMA. Each K/V layer carries its own join-and-release token, so any order of destruction is safe; the
elastic K/V's growth failure path releases every slot and retries once.
Restore of a 3K conversation 28-47 -> 7-12 ms on a CMP 170HX; outputs identical.

HIP: the runtime calls whose result the cache does not check are cast to (void) (the HIP shims declare hipError_t
[[nodiscard]]), so this commit carries its own HIP guard and builds alone for gfx1100 and gfx906.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…C, STRATA_CKPT_VRAM_SLOTS, STRATA_PARK_ASYNC, STRATA_CKPT_POOL=N, STRATA_CKPT_GATHER, STRATA_PARK_VRAM_MIB; CUDA, opt-in)

One section in DETAILS.md for the switches the four conversation-cache
commits add or extend: what each copies where, its VRAM / host memory cost,
that the first three stay off on HIP, gfx906 and Metal, the new cost of an
existing STRATA_CKPT_POOL=1 / STRATA_SLOT_CKPT_MOVE=1 setup (N x 118 MB
held from start-up), and the times measured on the CMP 170HX the commits
name.  STRATA_PARK_TRACE and the timestamp on the serve loop's STRATA_TRACE
lines are listed as diagnostics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…des their stream's priority

Opt-in (unset = as before) and bitwise by construction (no kernel changes, the same launches on the same inputs;
only the dispatch order moves):

STRATA_GRAPH_PRIO=1: the side mixer's branch stream (GDN gates, QSA indexer chain) is created at the device's
highest priority, cs_ and side2_ in the middle, the shared expert's stream at the lowest, and the window graph is
instantiated with cudaGraphInstantiateFlagUseNodePriority so each kernel node runs at its capturing stream's
priority instead of the launch stream's. The 1-block indexer kernels are then dispatched ahead of the q GEMV's
queued blocks and the shared expert behind the experts. A log line per captured window prints the node priority
histogram; if a driver did not copy the stream priority into the nodes, the capture-time notes (the branch forks
and the priority streams' tails) set the attribute per node (not needed on driver 610 / CUDA 13.3: the 3-token
window shows 120 x -5, 1063 x -2, 336 x 0, exactly the expected 36 + 84 high and 7 x 48 low).

Measured on the CMP 170HX (IQ3_S, --spec 3, 3K context, cmp_bench3 dec tok/s, interleaved in one lend, each arm
with STRATA_QIL=1):
  ref 125.3/126.6 | GRAPH_PRIO 127.8/129.0 | ref 125.0/126.8
  = +1.8 % (both GRAPH_PRIO runs above every reference run).
Greedy 11-prompt ids 11/11; the window node count is unchanged (1519 at T = 3 with QIL).

HIP: the prio_forks_ / prio_tails_ members (cudaGraphNode_t, not in the wave32 shim), capture()'s node-priority
block and the note_prio_fork / note_prio_tail / apply_node_priorities bodies sit under !STRATA_USE_HIP &&
!STRATA_HIP_GFX906 && !STRATA_GPU_METAL; init()'s cudaDeviceGetStreamPriorityRange query under !STRATA_USE_HIP &&
!STRATA_HIP_GFX906 (gprio = false otherwise); graph_prio_on() is false on HIP, gfx906 and Metal and
[[maybe_unused]].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ab), the clock warm-up and MMVQ_IL_TMIN

A go/no-go harness for an L2 weight prefetch, with its measurement on the CMP 170HX (GA100,
70 SMs, 32 MiB L2), T = 3.  Harness only; no engine code changes.

mmvq_il_parity --bench gains --hot (one weight copy: the L2-hot ceiling of a prefetch), --gap MB (MB of OTHER
weights read between the timed calls, as the window does between a prefetch and its consumer), --persist (a 20 MiB
persisting L2 set-aside + an access-policy window on the stream) and --ab (every arm per case inside ONE process:
cold / hot / hot+gap / hot+gap+persist / cold - the clocks ramp over the first ~100 ms of a run, so arms in separate
runs are not comparable; a memset gap was tried first and its write-backs made every arm slower than cold), plus a
clock warm-up and MMVQ_IL_TMIN.  Engine row choices (kIlRows_80), cold -> hot, us:
  z   2560x6144: IQ4_XS 16.0 -> 14.7 (-8 %), Q4_K 20.8 -> 17.7 (-15 %), Q5_K 22.3 -> 18.9 (-15 %), Q6_K 27.9 -> 20.7 (-26 %)
  out 6144x2560: IQ4_XS 16.1 -> 13.9 (-14 %), Q4_K 21.9 -> 19.2 (-12 %, multi), Q5_K 23.6 -> 20.5 (-13 %, multi), Q6_K 25.1 -> 20.4 (-19 %)
  ncu (caches kept vs flushed): L2 sector hit rate 100 / 93 / 96 % hot vs 24 / 21 / 16 % cold (Q6_K out, Q6_K z,
  IQ4_XS z), but at ncu's locked clock the duration moves only -4.5 / -10 / -3 %: the IL kernel is issue-bound once
  the lines are in L2 (sm__throughput 47-51 %).
  With 12, 16, 24 or 40 MB of other weights read between the prefetch and the consumer, plain LRU keeps NOTHING
  (hot+gap = cold or 1-2 us worse for every type at every gap), and the 20 MiB set-aside keeps a fraction: Q6_K z
  27.9 -> 26.0, Q6_K out 25.1 -> 24.6, Q4_K / Q5_K unchanged, IQ4_XS z 16.0 -> 17.6 (slower).  So an L2 prefetch
  (z forked 150 us ahead behind the experts' 58 MB, out 25-60 us ahead behind z's 9-13 MB) cannot meet its -15 %
  floor on this card: no-go.

gfx906 (the harness builds there with tests): persist_window's L2 access policy window and the --persist / --ab
set-aside query sit under !STRATA_HIP_GFX906; the harness prints "not available in this build".  The new timing
code ignores the runtime calls' results explicitly ((void)), so the harness adds no new nodiscard warnings to
the gfx906 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… (STRATA_MMVQ_IL_PF=1)

native_mmvq_il_pf_kernel is native_mmvq_il_kernel with every weight load of a group (4 chunks x R rows) issued
before any of the group's dots, in a branch-free main loop over the full groups (the old kernel's four
`if (kbx < bpr)` sections each sat behind their own branch, so a warp had one 2-row x 1-block group in flight;
ptxas also sinks a load whose only use is behind a branch into it), one pointer set per row with the other three
blocks at immediates, and the tail group clamped and predicated.  The dots use P2's exact integer forms:
__vsubss4(x, 0x20) for Q6_K's [0, 63] codes as (x + 0x60606060) ^ 0x80808080 (2 instructions for ptxas's 8), and
(float)(dp * sc) for |dp * sc| < 2^22 as the 1.5 * 2^23 magic add on the FMA pipe instead of the quarter-rate I2F
(same for Q4_K / Q5_K's dot1 * sc, dot2 * m, and IQ4_XS's sumi then times (float)(ls - 32), exact under 2^24).
The per-lane accumulation order, the v-slot sums and the xor tree are untouched, so every output is bitwise
native_mmvq's: mmvq_il_parity checks each variant on every case, T 1-8, rows 1/2/4.

The main loop is 441 SASS instructions per 8 row-blocks (55 a row-block, from 89), and ncu's long-scoreboard
stall drops from 3.0 to 1.1-1.8 cycles an instruction; the win is then bounded by registers (128 at 4 blocks a
SM), so the choice of variant and rows a warp is a measured sm_80 table (kIlPf_80 / kIlPf58_80, variant << 4 |
rows, from --emit-table --pf 0,3,4): variant 3 (4 blocks a SM) or 4 (5 blocks a SM: the 2560-row shapes' 320
blocks fit one wave).  Only Q6_K is in the table: Q4_K / Q5_K / IQ4_XS are bitwise and measured too but tie
today's kernel within noise.  Tried and rejected (measured slower): hoisting without P2 (latency hidden but 16%
more instructions, issue-bound), a whole-group double buffer (168 registers, 3 blocks a SM) and a per-section ring
(spills at 2 rows a warp).

mmvq_il_parity --pf a,b,.. (interleaved medians, 5 rounds of 100), cold, engine clocks, T x rows a warp:
  Q6_K 2560x10240  T2 r2 37.6 -> 30.0   T3 r2 39.6 -> 37.0   T4 r2 45.8 -> 39.6   T5 r2 55.5 -> 48.5 us
  Q6_K 6144x2560   T2 r2 27.8 -> 19.1   T3 r1 25.1 -> 22.7   T4 r1 27.9 -> 23.8   T5 r2 45.5 -> 35.1
  Q6_K 2560x6144   T2 r2 25.3 -> 20.1   T3 r2 28.0 -> 23.9   T4 r2 32.5 -> 25.1   T5 r2 35.1 -> 30.9
Engine (cmp_bench3, 3K context, interleaved ref / on / ref / on): dec 125.5 126.7 | 127.0 127.9 | 124.4 126.0 |
127.1 127.9 tok/s (+1.4%); the 11-prompt greedy set 11 / 11.  Unset or 0: today's kernels; 2-6 force one variant
for every type (A/B; 6 = hoisted loads without P2).

HIP: the inline-PTX load helpers (ldg_nc_*, madw) and the Q6_K dot forms only the CUDA-only il_pf kernels use sit
under !__HIPCC__, and the traits' TYPE constants are [[maybe_unused]], so this commit carries its own HIP guard and
builds alone for gfx1100 and gfx906 with no new warnings in native_mmvq.cu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…witches)

DETAILS.md gets a section with the two switches, what each does, that both are bit-exact, and their decode gain on
the CMP 170HX (+1.8 % with STRATA_QIL=1, +1.4 %). MMVQ_IL_TABLE.md gets a paragraph on the kIlPf_80 / kIlPf58_80
tables (variant << 4 | rows, Q6_K only) and on measuring a card with mmvq_il_parity --bench --emit-table --pf 0,3,4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linxuhao

Copy link
Copy Markdown
Contributor Author

GPU runs on this revision. Everything below ran on one CMP 170HX:

How the runs were made:

  • Env: both arms of every pair use STRATA_PF_FUSED=1 STRATA_OWNED_PRICE=exact STRATA_NUMA_POOL=0. NUMA_POOL is set because of NUMA pool split on a two-socket Xeon (2x 6226R, 2x R9700): on/off numbers, and the "arena path" limitation #3708. Each arm then adds its own switches, named below.
  • Order: every arm starts a fresh engine, and the arms are interleaved off / on / off / on.
  • Prompt sets per arm:
    • 4K: three ~3K-word prompts;
    • 16K: two ~12K-word prompts;
    • 52K: one ~48K-word prompt (51,834 tokens);
    • decode: two 384-token decodes after a ~3K-word prompt.
  • Figures: each figure is the mean over one arm's prompts, tok/s as the engine reports it. Two pairs per comparison; "pair ratios" shows both.

Overall

stock stock + upstream switches merged + all switches merged vs stock
4K prompt 1,980 2,237 4,131 2.09x
16K prompt 2,889 3,030 4,458 1.54x
52K prompt 3,154 3,229 4,367 1.38x
decode 108.6 119.7 129.5 1.19x
  • Upstream switches: STRATA_SIDE_MIXER=2 STRATA_ATTN_DAILY=1 STRATA_GR_UP75=1 STRATA_GU_SWIGLU=1 STRATA_QSA_ROWS=1 STRATA_B6_MMVQ_ROWS=1 STRATA_GR_NORM_FAST=1 STRATA_GDN_CONVL2=1 STRATA_PREFILL_RING=16 STRATA_QIL=1 GLIBC_TUNABLES=glibc.malloc.hugetlb=1.
  • "Merged" adds this PR's switches on top of those: STRATA_PF_FUSED_NT=256 STRATA_PF_BATCH=512 STRATA_PLE_COMPACT=1 STRATA_PLE_COPY_STREAM=1 STRATA_EMBED_VRAM=1 STRATA_DEQUANT_SIDE=1 STRATA_PF_FUSED_WS=1 STRATA_GDN_TC=1 STRATA_CKPT_ASYNC=1 STRATA_CKPT_VRAM_SLOTS=4 STRATA_PARK_ASYNC=1 STRATA_CKPT_POOL=2 STRATA_CKPT_GATHER=1 STRATA_PARK_VRAM_MIB=2048 STRATA_GRAPH_PRIO=1 STRATA_MMVQ_IL_PF=1.
  • Merged against stock + upstream switches (same switches in both arms, so the difference is this PR alone): 4K 1.80x (pair ratios 1.794 / 1.810), 16K 1.45x (1.450 / 1.447), 52K 1.35x (1.344 / 1.346), decode +8.1 % (1.078 / 1.083).
  • Greedy answers in that pair (11 prompts, 256 tokens, temperature 0) are 11 of 11 identical. The A/B/A conversation-switch check is 5 of 5 identical. For reference, stock against stock + upstream switches is 10 of 11.
  • The stock + upstream switches column is a separate pair against stock. Its two pairs disagree at 4K (1.044 / 1.217); 16K is 1.014 / 1.083, 52K 1.010 / 1.037, decode 1.102 / 1.103.
  • Conversation switches, in the same merged pair, A/B/A check:
    • park 231-268 → 3.8-24.1 ms;
    • restore 97-110 → 38.0 ms for the first restore of a run, then 7.1-8.7 ms.

Per feature

Each pair is the PR's switches off/on on that branch's own build. PR 1's defaults are compared against stock main. Percentages are on/off means. The pair ratios are in brackets.

PR switches on 4K 16K 52K decode greedy 11 conversation check
1 (the two sm_80 defaults) vs main +4.2 % (1.041 / 1.044) +3.0 % (1.027 / 1.033) +2.6 % (1.029 / 1.023) +2.9 % (1.030 / 1.027) identical -
1 PF_FUSED_NT=256 PF_BATCH=512 +7.6 % (1.111 / 1.044) +4.9 % (1.063 / 1.036) +4.3 % (1.051 / 1.036) +0.1 % identical -
2 PLE_COMPACT PLE_COPY_STREAM EMBED_VRAM DEQUANT_SIDE +2.5 % (1.022 / 1.028) +2.6 % (1.024 / 1.028) +2.2 % (1.024 / 1.019) +0.6 % identical -
3 PF_FUSED_WS=1 (both arms with NT=256 / BATCH=512) +0.7 % (0.970 / 1.046) +5.1 % (1.038 / 1.063) +6.8 % (1.063 / 1.072) -2.3 % (0.976 / 0.978) 10 of 11 -
3 GDN_TC=1 (same base) +3.9 % (1.035 / 1.043) +6.0 % (1.061 / 1.059) +6.7 % (one pair) -2.6 % (0.987 / 0.960) 10 of 11 1 of 5 identical
4 the six conversation-cache switches +47 % (1.421 / 1.522) +16 % (1.142 / 1.181) +6.4 % (1.058 / 1.070) 0 % identical 5 of 5 identical
5 GRAPH_PRIO MMVQ_IL_PF +3.9 % (1.008 / 1.072) +1.2 % (0.996 / 1.028) +0.2 % +1.3 % (1.013 / 1.013) identical -

Notes:

  • PR 4's prompt gain is inside the prompt read itself. With the switches off, a 3.3K-token prompt reads in 1,508-1,586 ms. With them on, it reads in 1,081-1,115 ms.

  • Parks are logged separately and are not part of those times: 339-366 ms off, 6.6-11.9 ms on.

  • We have not split the read-time difference by switch. In the conversation check, park went 377-447 → 2.7-19.5 ms and restore 115-128 → 17.4-81.8 ms.

  • PR 4's greedy result comes from a rerun. In the queue run, the off arm's engine stopped answering at the 9th prompt, so it produced no file. The rerun was two off arms, one on arm and two stock arms. All finished, and all were 11 of 11 identical to each other.

  • A stop on upstream's conversation-cache path. In 3 of about 35 engine starts the engine stopped answering. Every time it was running upstream's conversation-cache code (the PR 4 switches off):

    • stock at 9de04cd;
    • PR 4's off arm here;
    • PR 3's GDN_TC on arm here. That is why PR 3's GDN_TC row has one 52K pair.

    Each log ends on a park that evicts a parked conversation (evictions=3 / 4), and the next request gets no answer. A rerun of the same sequence did not reproduce it. We now keep the container output and will open a bug report with it if it comes back.

  • PR 3's decode -2.3 / -2.6 %. The two switches change prompt kernels only, so we think the decode numbers move because the rounding changes the generated text and with it the draft acceptance. We have not checked this.

  • Noise. Several 4K pairs disagree with each other (PR 1 NT, PR 3 WS, PR 5). We read those cells as within the noise. The 16K / 52K / decode cells where both pairs agree are the ones we rely on.

Not run on this revision: teacher-forced KL for PR 3's two rounding-level switches, any AMD or SYCL execution, and other sm_80 cards.

🤖 Generated with Claude Code

@linxuhao
linxuhao marked this pull request as ready for review October 23, 2026 08:12
@linxuhao linxuhao changed the title [Engine] sm_80 (GA100) tuning from #3712, all five features in one draft: one commit each, to split or cherry-pick as you prefer [Engine] sm_80 (GA100) tuning from #3712, all five features in one PR: one commit each, to split or cherry-pick as you prefer Oct 23, 2026
@linxuhao

Copy link
Copy Markdown
Contributor Author

Teacher-forced KL of PR 3's two rounding-level switches (CMP 170HX, PR 3's build on 011c9e6, 250 W).

Method:

  • Tool: tools/quant_quality.py prompt / pcompare, set v1: 20 prompts of 256-1,000 tokens (code, docs, English, other languages, agent).
  • What is compared: the full-vocabulary KL of the next-token distribution after each prompt, against the bitwise base.
  • Base: STRATA_PF_FUSED=1 STRATA_PF_FUSED_NT=256 STRATA_PF_BATCH=512.
  • One change to the tool's fixed settings, the same in every arm: --expert-cache auto (every expert resident, as in our benchmarks) instead of 1,500 slots. On this card's Gen2 x4 link, 1,500 slots would stream ~45 GB of experts per prompt.
arm top-1 same as base KL mean median max
base, second run 100 % 0 0 0
STRATA_GDN_TC=1 100 % 0.0157 0.0011 0.098
STRATA_PF_FUSED_WS=1 / STRATA_PF_FUSED_F16=1, with STRATA_PREFILL_STREAM_MIN=256 in every arm 100 % 0.0062 0.00006 0.076
both, with STRATA_PREFILL_STREAM_MIN=256 in every arm 100 % 0.0165 0.00013 0.102
  • The fused prompt-expert path only runs at chunks of stream_all_min() (1,024) tokens or more. The set's prompts are at most 1,000 tokens, so a plain run leaves WS / F16 unused: KL 0, identical to base. The WS / F16 rows therefore set STRATA_PREFILL_STREAM_MIN=256 in every arm, base included. The engine log confirms the fused path ran in those arms.
  • WS and F16 give identical results, as designed: the warp-specialized kernel computes the F16 path's bits.
  • For scale, docs/QUANTS.md reports the K-quant MMQ kernels against the FP16 path at mean 0.095 / median 0.0027.

🤖 Generated with Claude Code

@bitaccesscomau

Copy link
Copy Markdown

Independent data for STRATA_CKPT_ASYNC, from one RTX 3090 24 GB on PCIe 3.0 x16 (driver 580.178.04, CUDA 13.0, Xeon
E5-2698 v4 host):

  • At v0.2.4.1 release defaults the synchronous turn checkpoint cost 72.4 ms per request (median of 6, 71.2 to
    73.2 ms; the stream sync itself 0.0 to 0.1 ms), so on this card too the cost is the copy.
  • Before I found this PR I built a variant without a VRAM stage: one 112.6 MiB pinned host buffer allocated at start,
    the turn checkpoint only, one GPU. Its checkpoints were byte-identical to the synchronous save in 9 of 9 comparisons
    (118,063,152 bytes each). On v0.2.4.1, with STRATA_CKPT_POOL=1, STRATA_TOPK_SPLIT=1 and explicit
    conversation-cache settings in both arms, the tiny-prompt time to first token went from 110.8 to 101.9 ms (OFF / ON
    median 1.090 over 4 pairs, range 1.074 to 1.096).

One question from reading the code (unconfirmed, not measured): does the VRAM stage come out of the expert cache when
--expert-cache auto sizes it from free VRAM? On this card the engine has 23,738 MiB in use at ready.

I am not opening a competing pull request. If it helps, I can run this PR's STRATA_CKPT_ASYNC=1 on the same card
and post the numbers here.

@linxuhao

Copy link
Copy Markdown
Contributor Author

Thanks, this is useful. Your 72.4 ms matches what we measured with reused vectors (71.4 ms), so the copy dominates on PCIe 3.0 x16 as well as on our Gen2 x4.

On your question (from the code, not measured on your card): the stage does not come out of the expert cache. It is allocated lazily by the first turn checkpoint save. That happens after the engine is ready, when --expert-cache auto has already sized itself from free VRAM. The stage is one device buffer the size of the GDN state (your 112.6 MiB) plus a pinned host buffer of the same size, and both are reused afterwards. If the cudaMalloc fails, the save falls back to the synchronous copy without an error.

The flip side is that the stage has no headroom check of its own. STRATA_CKPT_VRAM_SLOTS has one: it only allocates while 1.5 GB stay free, so it would stay off on your card. With about 840 MiB free at ready, the stage leaves about 725 MiB for anything allocated later. I have not tested whether that can make a later allocation fail on a card that full. A long prompt after a few turns would be the case to watch.

Yes, please run it. The branch is based on 011c9e6 (0.2.2). The checkpoint change is the single commit 0d51292, which applies without conflicts onto v0.2.4.1 (checked textually, not built there). Your pinned-only variant is also a good data point: on a fast link, the VRAM stage may not be worth its 113 MiB.

🤖 Generated with Claude Code

@bitaccesscomau

Copy link
Copy Markdown

Thanks. I ran 0d51292 on its own, cherry-picked onto main 4b9e05a (clean, same patch-id) and built with setup's CUDA line for this card (sm_86), on one RTX 3090 24 GB
on PCIe 3.0 x16 at 250 W (driver 580.178.04, CUDA 13.0, Xeon E5-2698 v4 host in a KVM guest), at release defaults with a fresh engine per arm.

Risk case: completed. Fresh engine, four turns that extend each other (24K tokens), then a fresh 253,915-token prompt, a fresh 200,034-token prompt
and back to the parked conversation, with the switch on, both at release defaults and with deterministic settings:

  • The stage shows as +114 MiB at the first checkpoint save, in all 10 switch-on arms. The driver keeps 452 MiB reserved on this card, so free at ready
    is 387 MiB off and 273 MiB on, not about 840. The engine then grows by 126 to 132 MiB on the first real prompt in both arms and stays flat through 254K.
  • Sampled every second: peak 23,978 MiB used, minimum free 147 MiB with the switch on and 261 off (141 and 255 in a short-prompt check).
  • No error, out of memory, cudaMalloc or fallback line in any arm. Since the fallback is silent, I also ran it with STRATA_PARK_TRACE=1: 42 of 42 saves printed async ckpt d2d.

Identity. With STRATA_PREFILL_CPU_SHARE=0, --pcie-frac 0.32 and --adapt-every 0, main vs your build with the switch unset (6 tiny chats, 1K, 32K,
a 3-turn conversation and a turn that rolls back to an earlier turn checkpoint) and unset vs on (the same plus all 7 risk-case requests) were byte-identical,
with the same prompt N tokens = R reused counts. STRATA_CKPT_VRAM_SLOTS=4 stays off here, as you said.

Speed, release defaults, 4 pairs (OFF ON ON OFF, then ON OFF OFF ON). Tiny-prompt TTFT (engine prompt ms, median of 6 chats) OFF / ON: 353.8 / 339.8,
355.1 / 347.3, 337.1 / 325.5 and 361.2 / 334.5 ms, ratios 1.041, 1.022, 1.036 and 1.080, median 1.038 (about 13 ms of 350). Median ratios ON / OFF:
prefill 1K / 4K / 32K 1.001 / 1.001 / 1.003, decode tiny / 1K / 4K / 32K 1.009 / 0.999 / 1.001 / 1.003.

Why it is small here (STRATA_TRACE=1): the turn checkpoint save goes from 73.2 to 62.4 ms (median of 13) and a park's live-state save from 67.5 to
55.4 ms, so only about 11 ms per save leaves the path on this link, and the first save pays for the stage once (107 vs 75.5 ms). In a park, the K/V copy
right after gets slower with the switch (22 MB: 14.4 and 15.2 ms off, 21.6 and 27.6 ms on), so parks of a few hundred tokens or more gain little or nothing.
Reading the code (unconfirmed, not profiled), what stays on the path looks like the resize and zero fill of the fresh 118 MB host vector before the staged copy,
plus the synchronous PLE and per-layer tail copies after it. That also corrects my earlier note that the 72 ms here is the copy.

Build break. With -DSTRATA_BUILD_CONVERSATION_TESTS=ON, conversation_file_test and session_save_reclaim_test no longer compile: conversation_file.cpp
now includes conversation_snapshot.hpp, which pulls in cuda_runtime.h through session.hpp, and those targets have no CUDA include path (main builds and passes both). From reading
the CMake (not tried), they would then fail to link conversation_checkpoint_join. The other conversation tests pass, conversation_snapshot_test on the GPU too.

Not tested: the sampled thinking-on tier, 128K and 256K speed tiers, STRATA_CKPT_POOL=1 with the switch (by reading, it should drop the vector allocation), VRAM
slots with 1.5 GB free, parallel slots, images, HIP, SYCL, Metal or Windows builds, and multi-GPU (by reading, a device change frees and reallocates the stage).

…, not in conversation_file.cpp

0d51292 (STRATA_CKPT_ASYNC) and 3e0c141 (STRATA_PARK_ASYNC) made conversation_file.cpp include
conversation_snapshot.hpp and call the joins, so conversation_file_test and session_save_reclaim_test (built
from conversation_file.cpp without CUDA) stopped compiling with STRATA_BUILD_CONVERSATION_TESTS=ON.

The joins move to the start of the SAVE/RESTORE handler in generate.cpp. That is also earlier: the save copies the
checkpoints (session_save_checkpoints) and may reclaim some before put_checkpoint ran, and the join there only
matched the copied vector, not the one the copy in flight writes. conversation_file.cpp is upstream's again.

Reported by bitaccesscomau in Niko1221#3779.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linxuhao

Copy link
Copy Markdown
Contributor Author

Thank you, this answers the open question and found a real bug.

Headroom. With a minimum of 147 MiB free and no fallback in 42 of 42 saves, I will not add a headroom check to the stage. Thanks for correcting the free-at-ready figure: I had not counted the 452 MiB the driver keeps reserved.

Build break: fixed in 33a8146 (on top of the branch, so 0d512925 alone still has it):

  • The joins that 0d512925 (CKPT_ASYNC) and 3e0c141c (PARK_ASYNC) put into conversation_file.cpp move to the start of the SAVE/RESTORE handler in generate.cpp. conversation_file.cpp is upstream's file again.
  • Moving them also fixes a race your report led me to. session_save_checkpoints copies the deepest checkpoint (out.push_back(*deepest)) before put_checkpoint runs. The join in put_checkpoint then matched only the copy's buffer, not the vector the background copy writes into. So a SAVE that arrived within one checkpoint copy after a turn could write an incomplete GDN state. Only with STRATA_CKPT_ASYNC=1, and not observed.
  • Checked: with -DSTRATA_BUILD_CONVERSATION_TESTS=ON (CUDA configure), the two targets failed on cuda_runtime.h before the fix. After it, all five conversation tests build and pass: file 201, session_save_reclaim 119, cache 4,772, split_failure 16 and memory 23 checks. The engine builds for CUDA sm_80, HIP gfx1100 and gfx906. I have not rerun conversation_snapshot_test on the GPU.

Speed on a fast link. Your reading of the code matches mine:

  • Without the pool, checkpoint_save resizes a fresh vector before the staged copy, and that zero-fills 118 MB on the request path. With STRATA_CKPT_POOL=1 (upstream's switch) the vector should come from the pool already sized, so the resize does nothing. I have not measured that on its own.
  • The PLE and per-layer tail copies after it are synchronous too. STRATA_CKPT_GATHER=1 (commit 56088e71 in this PR) makes those 37 copies one.
  • So at release defaults on PCIe 3.0 x16, the switch alone saves about 11 ms per save. Your numbers with the pool and gather switches on would show how much of the remaining 62 ms they remove, if you have time.

The K/V copy getting slower during a park is expected: both copies share the one copy engine. I have no fix for that here.

🤖 Generated with Claude Code

Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
… chunked scan on the tensor cores, sm_80 (#3779, #3712)

Problem: the prompt path's DeltaNet recurrence is a large part of the prompt time
of a GA100 card.

Change, opt-in and rounding-level (the output is not the FP32 kernels' bits):
src/prefill/gdn_tc.cuh holds the chunked WY-form recurrence with every product
on the tensor cores (f16x3 splits with power-of-two scaling, one block per value
head) and its prep kernel; kernels.cu dispatches it before the STRATA_GDN_CHUNKED
branch for prompt chunks of 128+ tokens when STRATA_GDN_TC is set.

Where it runs and where it does not:
- gdn_rec_tc returns "not supported" and the existing kernels run unchanged on a
  card below sm_80, with fewer than 48 SMs, or with less than 122 KB of
  shared memory a block (the kernel needs 124,944 B). That excludes sm_86, sm_89
  and sm_120 (99 KB opt-in): the code is compiled into those builds and never
  launched there. It runs on sm_80 and sm_90. A failed scratch allocation
  (60.3 MB a device) also falls back.
- Device code for cards below sm_80 compiles to trapping stubs behind
  STRATA_GDN_TC_SM80; the whole header sits under !__HIPCC__.
- Unset, the dispatch costs one cached flag test per call.

Tests: gdn_rec_parity gains the tc variant (T = 1..4099, 8192, 32768, six stress
inputs against gdn_rec_kh_kernel, gate 1e-4; "not on this card" where it cannot
run), --selftest-frag (each tensor-core helper on one warp against a double
reference) and --bench-tc. The new ctest entry gdn_rec_parity_selftest_frag runs
the helper self-test, which any sm_80+ card can run (77 below sm_80). The full
tc variant can only run on an sm_80 or sm_90 card.

Reporter's numbers (linxuhao, CMP 170HX): recurrence 105 -> 34 ms at 3.3K tokens
and 1,705 -> 547 ms at 52K; prompts +3.9 % (4K), +6.0 % (16K), +6.7 % (52K) over
the same base; within 2.3e-6 of the FP32 kernel in gdn_rec_parity; teacher-forced
KL against the bitwise base mean 0.0157, median 0.0011, max 0.098, top-1 100 %;
1 of 5 conversation checks identical and decode -2.6 % in their pair (they
think draft acceptance moves with the rounding; not checked). Theirs, not
measured here.

Original change by linxuhao (PR #3779, commit f1cabc9).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…-to-host copy) and STRATA_CKPT_VRAM_SLOTS (#3779, #3712)

Problem: on a slow host link a turn checkpoint copies the whole GDN state to
the host on the request path (118 MB at 1.64 GB/s on a PCIe Gen2 x4 card, 72 ms;
an independent RTX 3090 on PCIe 3.0 x16 showed 73 ms to 62 ms with the switch).
Restoring a parked conversation copies it back.

Change, both opt-in and CUDA only (off on HIP, gfx906 and Metal):
- STRATA_CKPT_ASYNC=1: the GDN state is copied device to device into a VRAM stage
  (one buffer the size of the state, allocated at the first checkpoint save, plus
  a pinned host buffer of the same size; if either allocation fails the save falls
  back to the synchronous copy), and a background thread moves it in 4 MiB pieces
  into the checkpoint's host vector. Every reader joins first and only for the copy
  it needs (restore, snapshot save and restore, the parked copies, the serve loop at
  each request start, the SAVE/RESTORE session handler).
- STRATA_CKPT_VRAM_SLOTS=N (with ASYNC): the last N saved GDN states stay in VRAM,
  tag-checked, only allocated while 1.5 GB stays free; a restore of a checkpoint
  whose tag a slot holds copies device to device.
- STRATA_TRACE lines carry a steady-clock timestamp; STRATA_PARK_TRACE prints the
  timing of the save steps.
- Unset: the code adds a mutex-guarded no-op join per checkpoint save, restore and
  snapshot, a tag field that stays 0 and a few clock reads; the copies are the
  synchronous ones as before.

Differs from the PR as submitted: the PR put a join into conversation_file.cpp
(put_checkpoint), which broke the conversation_file_test and
session_save_reclaim_test builds without CUDA and matched only the copied vector,
not the one the copy in flight writes, so a SAVE within one checkpoint copy after a
turn could write an incomplete GDN state (found by bitaccesscomau; fixed in the
PR's last commit 33a8146). This commit carries that fix: conversation_file.cpp
stays as it is and the join runs first in the SAVE/RESTORE handler of
generate.cpp. The commit 33a8146 is therefore not a separate commit here.

One more join, found by running the new ctest entries: conversation_snapshot_sources (the SAVE
path of a session file) saves the running state with the background copy and the caller writes
it to the file at once, after the handler's join has already run. It now joins the copy of
that checkpoint before it returns; without it a session file written with
STRATA_CKPT_ASYNC=1 could hold an incomplete GDN state (conversation_snapshot_test failed
at "both files read" before the join).

Tests: conversation_snapshot_test now joins after each save and, with
STRATA_TEST_BIG_GDN=1 (a GDN state over 1 MiB, so the async path is taken), runs as
the new ctest entries conversation_snapshot_test_ckpt_async and
conversation_snapshot_test_ckpt_vram_slots: every checkpoint, capture and restore
must give the same bytes as the plain run.

Not covered: no test destroys a checkpoint while its copy is in flight (readers
join; the cache's eviction paths do not); the reporter saw 3 of about 35 engine
starts stop answering after a park with evictions, on the paths with the
switches off, and could not reproduce it in 15 reruns. The VRAM stage has no
headroom check of its own (an RTX 3090 with 147 MiB minimum free showed no failure
in 42 of 42 saves; the allocation failure falls back silently).

Reporter's numbers (linxuhao, CMP 170HX): turn checkpoint 71.4 -> 0.9 ms,
restore 114-126 -> 28-47 ms. Independent data (bitaccesscomau, RTX 3090 24 GB,
PCIe 3.0 x16, release defaults, 4 pairs): checkpoint save 73.2 -> 62.4 ms, tiny-prompt
time to first token median ratio 1.038, outputs byte-identical. Theirs, not measured
here.

Original change by linxuhao (PR #3779, commits 0d51292 and 33a8146).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…ackground and moves the dropped checkpoints (#3779, #3712)

Problem: parking the outgoing conversation copies its K/V device to host and
deep-copies its checkpoints before the next prompt can start: 213 ms at a 3K
context, 334 ms at 13K on a PCIe Gen2 x4 card (the reporter's CMP 170HX).

Change, opt-in and CUDA only (off on HIP, gfx906 and Metal):
- STRATA_PARK_ASYNC=1: conversation_kv_save copies the device K/V device to
  device into a VRAM stage (only while 1.5 GB stay free beside it; otherwise the
  save copies as before), and a background thread moves the stage into the host
  buffers in 4 MiB pieces through two pinned buffers. conversation_park_join
  waits for it: every reader of a parked image's K/V, the cache before it frees an
  entry, and the image itself (SavedConversation::pending) before its buffers go.
  ConversationBuffer::resize_for_overwrite leaves the added bytes unfilled for
  that copy; the buffer's segments use an allocator that value-initializes only
  when a fill value is given, and resize(n, value) is unchanged for every other
  caller.
- With it, the checkpoints this request drops right after the park (all of them
  when another state comes in, else the ones off this prompt's path) move into
  the parked image instead of being copied, and a background allocation refills
  STRATA_CKPT_POOL for the next save. Only the main session's checkpoints move,
  not an idle batch slot's (the park_source of #2526 serves both).
- conversation_snapshot_save takes dropped checkpoints' storage (the existing
  STRATA_CKPT_POOL) for its copies; with the pool off, nothing is taken.
- Unset: park_drops is null, no storage is taken, the K/V copies are the
  synchronous ones; the added work is the K/V size sum for the stage request and
  a few join calls.

Differs from the PR as submitted: the PR's join in conversation_file.cpp
(put_payload) is not ported; the SAVE/RESTORE handler of generate.cpp joins the
park first (see the previous commit, which carries the PR's 33a8146 fix).
Hand-merged into the park_source / ParkSource refactor of #2526.

Not covered: if the background copy fails after resize_for_overwrite, the
parked image keeps unfilled bytes; the failure is logged. A restore of such an
image would read them. No test of the park path with a failing copy.

Tests: the PARK_ASYNC path has no unit test in this commit (it needs a card with 1.5 GB free for the
stage); the reporter's A/B/A conversation check covers it, see the checklist.

Reporter's numbers (linxuhao, CMP 170HX): park 213 -> 64-75 ms (3K), 334 -> 68 ms
(13K); greedy answers identical on their 11 prompts and the A/B/A conversation
check. Theirs, not measured here.

Original change by linxuhao (PR #3779, commit 3e0c141).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…DY=N, gathered small checkpoint copies STRATA_CKPT_GATHER=1, park traces (#3779, #3712)

Problem (reporter, CMP 170HX, PCIe Gen2 x4): a conversation switch's park still
paid 38-52 ms for the live state, because the STRATA_CKPT_POOL pool was empty at
every park (a switch takes two checkpoints), so the 118 MB GDN vector was
allocated fresh and page-faulted; and a checkpoint's 37 small synchronous
device-to-host copies queue behind the 4 MiB DMA pieces of STRATA_CKPT_ASYNC.

Change:
- STRATA_CKPT_POOL_READY=N (opt-in, 1..16, with STRATA_CKPT_POOL on): the pool
  keeps N ready checkpoints, pre-sized to the session's state sizes with their
  pages in, topped up on a background thread after any take (not with a layer
  split).
- STRATA_CKPT_GATHER=1 (CUDA, opt-in): the small running-state vectors (ple, and
  each layer's indexer tail, dead row and block position) are gathered device to
  device into one buffer and fetched with one copy into a pinned buffer.
- The park stage is allocated rounded up to 64 MiB (a growing conversation does not
  cudaFree/cudaMalloc it every time) and a VRAM-resident K/V skips the pointer
  attribute query.
- The background helpers (pool top-up, the checkpoint DMA, the park DMA) leave the
  host's pinned core (aux_cpus.hpp); no output depends on where a thread runs.
- A parked image whose live GDN state is still being copied joins before its
  buffers are freed (SavedConversation::live_pending).
- STRATA_PARK_TRACE prints the joins, the live-state vectors' readiness and the
  K/V sub-timers.

Differs from the PR as submitted: the PR made STRATA_CKPT_POOL=1 allocate two
ready checkpoints at startup (+236 MB of host RSS) and read STRATA_CKPT_POOL=N as
a count. That changes what an existing switch does, so here STRATA_CKPT_POOL=1
keeps its meaning (the pool holds dropped checkpoints only, nothing is allocated
ahead of time) and the ready pool has its own variable, STRATA_CKPT_POOL_READY.
A user of the PR's STRATA_CKPT_POOL=2 sets STRATA_CKPT_POOL=1
STRATA_CKPT_POOL_READY=2. The PR's one-checkpoint refill after a park with
STRATA_PARK_ASYNC is replaced by the top-up, as in the PR, and so is only active
with the new variable. Merged by hand into #2526's park_source.

With none of the new variables set: the stage size is rounded up only when the
park stage is on, the extra code is trace output behind STRATA_PARK_TRACE and
the join calls.

Reporter's numbers (linxuhao, CMP 170HX): park at a 3K context 64-75 -> 21-40 ms
(all park switches together, A/B/A conversation check identical, greedy 11 of 11
identical). Theirs, not measured here.

Original change by linxuhao (PR #3779, commit 56088e7).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…sations' K/V in VRAM in front of the host copy (#3779, #3712)

Problem: restoring a parked conversation copies its K/V from the host over the
link: 27-107 ms over a PCIe Gen2 x4 link.

Change, opt-in and only with STRATA_PARK_ASYNC=1 (CUDA only): a park also keeps
the whole K/V of its image in a VRAM slot of its own (64 MiB granules, up to N MiB
in all, only while 1.5 GB of VRAM stays free beside it); the background copy to
the host reads from the slot, and a restore of that image copies device to device
from the slot (about 1 ms) without waiting for the background copy. Each K/V
layer carries its own join-and-release token, so any order of destruction is safe
(the layers outlive their image when they are retained for the next park). Under
VRAM pressure the elastic K/V's growth releases every slot and the stage, then
retries once (both growth sites in generate.cpp).

With the variable unset the slot code is not reached: the budget is 0, a restore
finds dev_tag 0 and takes the host path, and the growth sites see
conversation_park_vram_release_all() return 0 and behave as before.

Tests: none of its own (it needs a card with the VRAM for the slot and a parked
conversation); see the checklist for the reporters' A/B/A conversation check with
STRATA_PARK_VRAM_MIB set.

Reporter's numbers (linxuhao, CMP 170HX): restore of a 3K conversation 28-47 ->
7-12 ms; outputs identical. The first restore of a run is slower (41.6 / 56.9 ms)
than later ones (6.5-12.6 ms) and they have not found why. Theirs, not measured
here.

Original change by linxuhao (PR #3779, commit bafca41).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…C and the park switches (#3779, #3712)

One section in DETAILS.md for the switches the four conversation-cache commits
add: what each copies where, its VRAM and host memory cost, that they stay off
on HIP, gfx906 and Metal, and the measured times.

Rewritten from the PR's section: the times are labelled as the reporter's
(linxuhao, one CMP 170HX on PCIe Gen2 x4; we have no sm_80 card and measured
none of them), the independent RTX 3090 data of bitaccesscomau is added, and the
pool row describes STRATA_CKPT_POOL_READY=N instead of the PR's change to the
meaning of STRATA_CKPT_POOL=N (STRATA_CKPT_POOL=1 is unchanged).

Original section by linxuhao (PR #3779, commit a04f439).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…rnel nodes their stream's priority (#3779, #3712)

Problem: the decode window graph runs every kernel node at the launch stream's
priority, so the one-block indexer kernels of the side branch wait behind the
queued blocks of the big q GEMV, and the shared expert competes with the routed
experts.

Change, opt-in (bitwise by construction: no kernel changes, the same launches on
the same inputs; only the dispatch order moves): with STRATA_GRAPH_PRIO=1 the
side mixer's branch stream (GDN gates, QSA indexer chain) is created at the
device's highest priority, cs_ and side2_ in the middle, the shared expert's
stream at the lowest, and the window graph is instantiated with
cudaGraphInstantiateFlagUseNodePriority. If a driver did not copy the stream
priority into the nodes, the capture-time notes (the branch forks and the
priority streams' tails) set the attribute per node. One log line per captured
window prints the node priority histogram. A card whose priority range is 0..0
changes nothing.

CUDA only: the members and the three methods sit under !STRATA_USE_HIP &&
!STRATA_HIP_GFX906 && !STRATA_GPU_METAL; graph_prio_on() is false there.

Merged by hand with #3702 (the window-graph eviction): instantiate_evicting now
takes both keep_t and the flags, and the retry after freeing other window
sizes keeps the flags (the merged code would otherwise have dropped the node
priorities on that retry).

With the variable unset: every stream is created as before and the capture-time
notes return at once.

Tests: none of its own (a priority is a scheduling hint with no value to compare).
The window graphs are exercised by the engine; see the checklist for the
reporter's greedy-answer identity check.

Reporter's numbers (linxuhao, CMP 170HX, IQ3_S, --spec 3, 3K context, interleaved in
one session, each arm with STRATA_QIL=1): reference 125.3 / 126.6, GRAPH_PRIO
127.8 / 129.0, reference 125.0 / 126.8 tok/s, +1.8 %. Greedy 11 of 11 identical. In
the later per-feature pair together with STRATA_MMVQ_IL_PF: decode +1.3 %. Theirs, not
measured here.

Original change by linxuhao (PR #3779, commit 68c487c).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…ab), the clock warm-up and MMVQ_IL_TMIN (#3779, #3712)

Harness only; no engine code. The bench part of mmvq_il_parity times the
interleaved matvec cold (24 copies of the weights rotated past the L2), hot (one
copy), hot with other weights' reads in between (--gap MB, what a window's GEMVs do
between a prefetch and its consumer), and with the timed weights in a persisting L2
set-aside (--persist); --ab runs the five arms per case in one process, because
the clocks ramp over the first ~100 ms of a run. A 150 ms warm-up of launches runs
before the first timed case, and MMVQ_IL_TMIN bounds T from below.

The parity part is unchanged: without --bench the same checks run in the same
order (every rows choice against native_mmvq, bitwise). The bench code is CUDA only
(the test is built with STRATA_ENABLE_CUDA); the persisting-L2 calls are left out
under STRATA_HIP_GFX906.

Original change by linxuhao (PR #3779, commit 1f4b813).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…eaved GEMV kernels for Q6_K (#3779, #3712)

Problem: the interleaved small-batch matvec (native_mmvq_il) has one weight block
in flight per warp: its four `if (kbx < bpr)` sections each sit behind their own
branch, so on a GA100 (ncu: 432 GB/s on the qkv Q6_K matrix) the loads of a group
are not issued together.

Change, opt-in (every variant is bitwise native_mmvq): native_mmvq_il_pf_kernel
issues every weight load of a group (4 virtual warps x R rows) before any of the
group's dots, with the addresses clamped to the row's last block for a tail, and
with the ring variants the next group's loads before this group's dots. The
per-lane accumulation order is the plain kernel's. P2's forms replace
conversions by exact ones where the value is a small integer: (float)p becomes
p + 1.5 * 2^23 minus 1.5 * 2^23 for |p| < 2^22, and the saturating byte subtract
of Q6_K's codes (0..63, so nothing saturates) becomes two instructions. Q4_K,
Q5_K, IQ4_XS and Q6_K have the variants; Q3_K (the Q2_0 pack's type) keeps the
plain kernel.
- STRATA_MMVQ_IL_PF=1: the measured table (kIlPf_80 / kIlPf58_80: Q6_K only, read
  only when the card is sm_80; on every other card 1 does nothing);
  =2..6: one variant for every type and shape (A/B only, any CUDA card).
- Unset or 0: the plain kernels through the same launch code (the variant argument
  is 0, and the added code is one cached environment read and a switch).
- CUDA only; HIP has no interleaved path.

Tests: mmvq_il_parity --pf a,b,.. bit-checks each listed variant against
native_mmvq for every type and shape, T = 1..8 and rows 0/1/2/4, and with --bench
times them interleaved (PFBENCH lines) and prints the joint (variant, rows) table
in the source form of kIlPf_<cc>. New ctest entry mmvq_il_parity_pf runs it with
--pf 0,2,3,4,5,6 on whatever card is present.

Reporter's numbers (linxuhao, CMP 170HX): decode +1.4 % (two reference / on / reference /
on sets) over the plain kernels; with STRATA_GRAPH_PRIO decode +1.3 % in their
per-feature pair (4K prompts +3.9 %, 16K +1.2 %, 52K +0.2 % in the same pair, which
the reporter reads as noise). Q4_K, Q5_K and IQ4_XS variants tie the plain kernels
there. Theirs, not measured here.

Original change by linxuhao (PR #3779, commit 116992a).
Niko1221 pushed a commit that referenced this pull request Oct 27, 2026
…TA_GRAPH_PRIO and STRATA_MMVQ_IL_PF (#3779, #3712)

DETAILS.md gets two sections: the decode switches (GRAPH_PRIO, MMVQ_IL_PF) with the
two sm_80-only defaults named, and the prompt switches (PF_FUSED_NT / BATCH / F16 /
WS, GDN_TC, PLE_COMPACT / PLE_COPY_STREAM, EMBED_VRAM, DEQUANT_SIDE) with what is
bitwise and what is rounding-level. MMVQ_IL_TABLE.md gets the sm_80 row of the table
list and a paragraph on the hoisted-load tables (kIlPf_80 / kIlPf58_80, Q6_K only) and
on measuring a card with mmvq_il_parity --bench --emit-table --pf 0,3,4.

Rewritten from the PR's text: every figure is labelled as the reporter's (one CMP
170HX; we have no sm_80 card and measured none of them), and the prompt-switch
section is new (the PR documented only the decode switches and the conversation
cache).

Original docs by linxuhao (PR #3779, commit 6d6f0f3).
Niko1221 added a commit that referenced this pull request Oct 27, 2026
… tables and the opt-ins)

# Conflicts:
#	CMakeLists.txt
#	docs/MMVQ_IL_TABLE.md
#	src/kernels/cuda/native_mmvq.cu
#	src/kernels/mmvq_il_parity.cpp
#	src/program/generate.cpp
Niko1221 added a commit that referenced this pull request Oct 27, 2026
…llow STRATA_DEFAULTS=026: back to the 0.2.6 kernels and the shared table; explicit =1 and =0 still win; arch_defaults_test checks the rule; release-notes line
Niko1221 added a commit that referenced this pull request Oct 27, 2026
P0 #3911 #1503; P1 fixes; file-02 items; 22 PR ports (authors kept) incl. #3779 (sm_80 defaults under
STRATA_DEFAULTS=026) and #4121 (STRATA_PIPED_MTP opt-in); audit fixes #3602 #3948 #3638 #3797 #2409; owner
decisions #3935 (DeepSeek sm_89 packages), #3844, #3453 (sm_89 tables on), #3976 (layer-major prefill on by
default for a 2-card CUDA layer split; revert 1e41fa269 to undo). Final gate: sm_60, SYCL, z440 sm_86 CPU 38/38
GPU 23/23, boston CPU 24/24 GPU 23/23, owner-PC MSVC CPU 36/36 GPU 20/20, snapshot 10/10 on z440 and boston.
@Niko1221

Copy link
Copy Markdown
Owner

Thank you. Fixed in 0.2.7: The sm_80 defaults and the opt-in sm_80 switches of #3779, STRATA_GRAPH_PRIO and STRATA_MMVQ_IL_PF (#3779, #3712). Your name is on the commit. We could check it with unit tests and builds, but not on your hardware or with your workload. Please try 0.2.7 and tell us how it goes.

Release: https://github.com/Niko1221/Strata/releases/tag/v0.2.7

fragtion added a commit to fragtion/StrataPool that referenced this pull request Oct 27, 2026
… forwarded to the workers

- The coordinator's CONFIG carries "env": the pool-wide opt-ins set on it (kPoolForwardEnv: Niko1221#4005's
  STRATA_PREFILL_COPY_EVENT_EVERY / _COPY_WAIT_DEDUPE, Niko1221#3779/Niko1221#3712's STRATA_PARK_ASYNC / STRATA_CKPT_ASYNC /
  STRATA_CKPT_GATHER, and STRATA_ROUTE_PRIOR).  A worker applies each one it has not set itself, before its first prompt,
  park, checkpoint or verifier reads it, and logs "strata pool: from the coordinator: ...".  STRATA_DEFAULTS=026 is not
  forwarded (a worker reads it before CONFIG) and the VRAM/RAM-sized switches stay per PC.
- <config>.pool.json takes "env": {STRATA_*: value} (checked: up to 32, STRATA_* names, short values without ; or
  newlines), applied to the engine of either split role through pool_env; the Pool tab's save keeps it.
- Niko1221#4010's STRATA_PIPE_ADAPT_EVERY start-up line also on a pool coordinator (the pipelined groups drain the workers'
  stages before a round; the workers already swap between the windows they serve).
- Checked for a worker: the async park's device copy is synchronised before the park returns (only the copy to RAM
  runs on), so a following prompt cannot overwrite what is being copied.
- docs/POOL.md: "Opt-in switches for a split" (what each does, pool-wide or per PC, how to set them).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157fjixQD2nRxvCvtXq66DD
@linxuhao

linxuhao commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor Author

Thank you for porting this into 0.2.7. Here are the results from the CMP 170HX (sm_80, PCIe Gen2 x4, 250 W, Qwen IQ3_S, every expert in VRAM), using the release tag built with the production CMake options. Each arm was run twice, interleaved.

The sm_80 defaults are bitwise identical. 0.2.7 against 0.2.7 with STRATA_DEQUANT_WIDE=0 STRATA_MMVQ_IL_SM80=0: all 11 greedy answers are identical.
Against STRATA_DEFAULTS=026, 10 of 11 were identical. The one difference is a single word in a long summary. It comes from the other 0.2.6 defaults that the switch also restores, not from the two sm_80 ones.

Prompt read (tok/s) 0.2.7, STRATA_DEFAULTS=026 0.2.7 defaults
4K 2,080 2,170 (+4%)
16K 3,030 3,110 (+3%)
52K 3,180 3,260 (+3%)
Decode 107-110 109-111

The opt-in switches work as ported. 0.2.7 defaults against 0.2.7 with the switches we run in production (the #3779 set plus upstream's SIDE_MIXER=2 ATTN_DAILY=1 GR_UP75=1 GU_SWIGLU=1 QSA_ROWS=1 B6_MMVQ_ROWS=1 GR_NORM_FAST=1 GDN_CONVL2=1 PREFILL_RING=16 QIL=1 CKPT_POOL=2):

defaults with the switches
4K prompt 2,170 3,780 (+74%)
16K prompt 3,110 4,340 (+40%)
52K prompt 3,260 4,290 (+32%)
Decode 109-111 124-128

The greedy answers were identical (11 of 11). As expected, the multi-turn conversation check differs in wording, because STRATA_GDN_TC=1 changes the summation order.

🤖 Generated with Claude Code

@48871697-creator

Copy link
Copy Markdown

Following your request in #3712 (2026-10-27) to try 0.2.7 on sm_80 and post the results here. All figures below are one A100 80GB (PCIe); multi-card numbers are not included.

A100 (sm_80) results: 0.1.40.3 → 0.1.52 on Qwen3.8-Flash-Next, plus a self-built sm_80 DeepSeek engine

Test box: 7× A100 80GB PCIe, Linux, driver 580.126.09, CUDA 12.8 and 13.1 toolchains, 128 cores, 1 TB RAM. One card is used per service (a dedicated card for Qwen, a 40 GB slice of another card for DeepSeek when sharing with production).


1. Confirming the CUDA-12 requirement (for #4461)

package engine-component.json archs
strata-engine-0.1.52-linux-x86_64-cuda-**cuda12**.tar.gz sm_60, sm_61, sm_70, sm_75, **sm_80**, sm_86, sm_89, compute_89
strata-engine-0.1.52-linux-x86_64-cuda-**cuda13**.tar.gz sm_75, sm_86, sm_89, sm_120, compute_120

So on A100 the CUDA 12 package is the only choice — that matches what you said in #4461. It runs on driver 580.126.09.

One nuance worth adding to the docs: the source tree does build sm_80 with CUDA 13.1. I compiled strata-dsv41 from main with -DCMAKE_CUDA_ARCHITECTURES=80 and CUDA 13.1.115 and the binary contains sm_80 code (cuobjdump, strings) and runs fine. The missing sm_80 machine code is a property of the shipped CUDA-13 packages, not of the source — this is the only reason DeepSeek runs on A100 at all here (the official dsv41 packages list sm_86 / sm_89 / sm_120).


2. Qwen3.8-Flash-Next IQ3_S: 0.1.40.3 vs 0.1.52 (same bench, same pack, same card)

The 0.1.40-era pack loaded in 0.1.52 unchanged (native pack: ... 302 canonical tensors skipped: served natively), so no repack was needed.

Method: POST /v1/chat/completions, temperature 0, reasoning_effort: none, max_tokens 400 ×3 after one warm-up. Prefill = TTFT with max_tokens 1 on a 78,018-token prompt. Single run per cell (not five paired runs), so treat these as indicative.

Config: --max-context 262144 --kv int8 --kv-resident 32768 --expert-cache auto --prefill auto --spec 6 --mtp .../mtp/rt --vision, expert cache 24,576 slots.

metric 0.1.40.3 0.1.52 change
load → ready 961 s (cold) / 360 s (warm page cache) 45–88 s 4–10× faster
prefill, 78K prompt 1,678 tok/s (46.5 s) 2,281 tok/s (34.2 s) +36%
decode, 400 tok (steady) 68.6 / 68.0 tok/s 67.2 / 67.6 tok/s equal
MTP acceptance — drafts_accepted 1091 / drafts_offered 1490 = 73% drafting=mtp, spec=8, mtp_max=6, spec_min_p=0.50

A measurement hazard others may hit: the first request after loading ran at 116 tok/s (old engine) and 90 tok/s (new engine) because of cache effects. Averaging it with the next two runs inflates the mean to 84 tok/s and makes the new engine look 22% slower. Compare steady-state runs only.

Two traps when moving an existing 0.1.40.3 setup to 0.1.52:

  1. --gpus 0 → --gpu 0 (the new engine answers unknown argument: --gpus).
  2. --vram-reserve-mib is gone (reserve is now runtime, POST /v1/vram / the VRAM <mib> command). Passing it makes the engine exit at once. Without it the engine takes almost all VRAM: 79.1 GB used / 2.05 GB free on an 80 GB card.
  3. Control and engine must be updated together — the old serve/server.py passes --gpus, which the new engine rejects.

For completeness: --ple-io ram made no difference here (1,728 vs 1,754 tok/s prefill), and STRATA_DEFAULTS=026 also made none (66 tok/s decode, 1,738 tok/s prefill), so the +36% prefill is not an artifact of our settings.


3. DeepSeek-V4.1-Flash-1017 on A100 (self-built sm_80 engine)

  • Weights: IST-DASLab 2.25 bpw set + Engram IQ4_NL (265.8 GB); pack via tools/dsv41/pack_gguf.py.
  • Engine: main @ build 0.1.51, CUDA 13.1.115, -DCMAKE_CUDA_ARCHITECTURES=80, -DSTRATA_BUILD_DSV41=ON.
setup decode
one card, 40 GB slice of an 80 GB card (the rest runs production), --reserve-mib 8192, --dspark auto 28.5 tok/s
same slice, --dspark on --dspark-block 10 35 tok/s

DSpark: auto refuses with "the routed experts (149 GB) are far more than the GPU holds (41 GB), where its verify rounds read 3.3x a token's bytes and gain nothing" — but forcing on is 20–38% faster on this box, so that heuristic is worth revisiting for the "few experts resident" case.

Context: 131,072 verified. A 130,543-token prompt was accepted, first token after 212 s, and a needle placed at 2/3 of the document was answered exactly. The model's own limit is 1,048,576 (yarn factor 16 on 65,536) but we did not test longer.

One config gotcha that cost me a day (may deserve a line in DEEPSEEK41.md): serve/server.py needs "arch": "deepseek41" in the config JSON. Without it the model family is not switched, and three things fail silently: tool calls are never emitted (the model answers "I have no file-system tools" and the turn ends — looks like the model stops mid-conversation), content stays null (the answer lands in reasoning_content), and reasoning_effort is mapped through the Qwen table (high → xhigh) and rejected by the V4.1 template. With the key set, DSML tool calls work (finish_reason: tool_calls) and none/low/medium/high/max all work. There is also a log line that confirms it: "this pack's chat template spells the tool-call block differently from the V4.1 model's; using the V4.1 template so tool calls come back as real calls".


4. Not tested here

  • Whether 0.1.52 changes Qwen answers on sm_80 (the notes claim bitwise-identical defaults; I did not run an eval harness).
  • The sm_80 table switches STRATA_DEQUANT_WIDE / STRATA_MMVQ_IL_SM80 in isolation.
  • Contexts beyond 131,072 for DeepSeek, or 512K/1M loading.
  • Vision on A100 (we run vision: cpu).
  • Multi-slot / batching numbers on sm_80.
  • Multi-card numbers (all figures above are one card).

Happy to run any specific measurement you want on this box.

@linxuhao

Copy link
Copy Markdown
Contributor Author

Thank you for running this on a real A100. One thing stands out: your absolute numbers are below what we get on the CMP 170HX. That card is a cut-down GA100 on a PCIe Gen2 x4 link (1.5 TB/s HBM, 250 W). Our results on 0.2.7 (engine 0.1.52), Qwen IQ3_S with every expert in VRAM:

your A100, 0.2.7 our CMP, 0.2.7 defaults our CMP, 0.2.7 with the switches
Decode 67 tok/s 109-111 124-128
Long prompt 2,281 tok/s (78K) 3,260 (52K) 4,290 (52K)

An A100 should be at least as fast, so we think the gap comes from the configuration rather than the card. These are the differences from our setup that we have measured on our side:

  1. --spec 6. On our card, decode was 109-111 tok/s with --spec 3, 95-106 with 4 and 87-93 with 5 (engine 0.1.46, MTP drafting). Deeper drafts cost more per window than the extra accepted tokens give back.
  2. The prompt path. We run --no-prefill-borrow --prefill 8192 with STRATA_PF_FUSED=1 STRATA_OWNED_PRICE=exact. When we measured this on 0.2.1, the default path borrowed about 2,660 expert-cache slots for its buffers and refilled them from RAM after every prompt. STRATA_PF_FUSED was off by default below sm_120. Together these took a 4K prompt from 945 to 2,280 tok/s on our card. The borrowing may also explain why your first request decoded at 90-116 tok/s and the later ones at 67: until the slots are refilled, some experts are not in VRAM. That is a guess; we have not seen this ourselves.

If you have time, could you run two more arms on the same card and the same bench? Our "0.2.7 defaults" column above is arm A, and the switches column is arm B.

A: same arguments as yours, except:

  • --spec 3 --spec-min-p 0.5
  • --no-prefill-borrow --prefill 8192
  • env STRATA_PF_FUSED=1 STRATA_OWNED_PRICE=exact

B: A plus these switches:

STRATA_SIDE_MIXER=2 STRATA_ATTN_DAILY=1 STRATA_GR_UP75=1 STRATA_GU_SWIGLU=1 STRATA_QSA_ROWS=1
STRATA_B6_MMVQ_ROWS=1 STRATA_GR_NORM_FAST=1 STRATA_PF_BATCH=512 STRATA_PF_FUSED_WS=1 STRATA_PLE_COMPACT=1
STRATA_DEQUANT_WIDE=1 STRATA_GDN_CONVL2=1 STRATA_PREFILL_RING=16 STRATA_EMBED_VRAM=1 STRATA_PLE_COPY_STREAM=1
STRATA_DEQUANT_SIDE=1 STRATA_GDN_TC=1 STRATA_GRAPH_PRIO=1 STRATA_QIL=1 STRATA_MMVQ_IL_PF=1

With all of them on, our 11 greedy answers were identical to the defaults. Our multi-turn check differed in wording, because STRATA_GDN_TC=1 changes the summation order in the recurrence (about 1e-6 relative).

If your host has two sockets, STRATA_NUMA_POOL=0 was also faster on ours (#3708).

One question about the side checks: --ple-io ram (1,728 vs 1,754 tok/s) and STRATA_DEFAULTS=026 (1,738 tok/s) are about 25% below the 2,281 in your table. Were those runs on the same 78K prompt and the same arguments? If so, the 0.2.7 defaults would account for about +31% on the A100, which is much more than the +3-4% we see on the CMP. That would be worth knowing.

🤖 Generated with Claude Code

@48871697-creator

Copy link
Copy Markdown

Thanks — you are right that it was configuration, and worse: two of my numbers were measurement artifacts. Correcting them first, because they invalidate part of what I posted.

Two corrections to my earlier comment

  1. Prefill was inflated by prompt-cache reuse. My probe reused the same document (base × n), so after the first arm the engine served a cached prefix while usage.prompt_tokens still counted it. prompt_tokens / wall time then over-reports. I now send a unique nonce per probe and divide only prompt_tokens - prompt_tokens_details.cached_tokens.
  2. Decode 67 tok/s was an artifact. It came from repeated identical prompts right after load. With unique prompts and 512-token answers (median of 3), the baseline is 119 tok/s, not 67.

So the honest picture is the one you predicted: decode is level with your CMP; the prompt path is ~35% behind. And to answer your question directly: --ple-io ram (1,728) and STRATA_DEFAULTS=026 (1,738) were the same 78K prompt and the same measurement code as the 2,281, but each was a single run, and the same settings gave me 1,754 and 2,281 in different runs. I withdraw the "+31% from 0.2.7 defaults" inference — it was noise plus the cache artifact.

Arms, same card, same bench, corrected method

Median of 3 × 512-token decodes with unique prompts; prompt = median of 2, new tokens only, unique documents. One A100 80GB PCIe (2.0 TB/s HBM, x16 Gen4), driver 580.126.09, engine 0.1.52 (cuda12 package), Qwen IQ3_S, 24,576 expert slots (= every expert), vision on.

arm decode prompt 52K prompt 78K
D: our config (spec 6, --prefill auto, defaults) 119.3 1,703 1,660
A: your suggestion (spec 3, --prefill 8192, PF_FUSED, OWNED_PRICE) 115.6 1,846 1,894
A2: A + STRATA_NUMA_POOL=0 103.3 / 116.3 1,943 1,875
B: A + your switch list (no PF_FUSED/OWNED_PRICE/NUMA) 131.6 1,800 1,828
B2: A + switch list + STRATA_NUMA_POOL=0 115.6 / 133.4 2,370–2,479 2,331–2,438

For reference, your CMP: decode 109–111 / 124–128; prompt 3,260 / 4,290 at 52K.

  • --spec 3: no decode change on this card (115.6 vs 119.3), unlike the 87–93 you saw at spec 5.
  • Your switch list is a real win on prompt: +40–47% (1,703 → 2,370–2,479), decode unchanged within spread. That is the best arm so far and I will keep it on.
  • STRATA_NUMA_POOL=0 is not reproducible here. One run gave 236.8 decode, the repeats gave 103.3 and 116.3 — so the 2× was an outlier, not a host property. Two sockets is correct (numactl --hardware: 2 nodes), 128 cores. Not recommending it as a default on our box.

Two notes on the flags you listed

  • --no-prefill-borrow does not exist in 0.1.52 — --help has no borrow flag at all; --prefill CHUNK is there (auto = largest chunk up to 8192 whose buffers the expert cache can lend), so the borrowing behaviour is still present but no longer switchable from the CLI. Arm A above is therefore "spec 3 + prefill 8192 + PF_FUSED + OWNED_PRICE" only. If the disable switch still exists under another name I will use it.
  • Every env name in your list resolves in the 0.1.52 binary (strings finds all 20), so B2 was genuinely applied.

What is left of the prompt gap, and a question

With your switches we are at 2,370–2,479 against your 3,260–4,290, so ~30% is still unexplained, and I do not think it is expert residency. What differs from your setup on our side:

  • --kv int8 --kv-resident 32768 under --max-context 262144.
  • What do you run for --kv, --kv-resident and --max-context? If you keep the whole KV in VRAM with a small context, matching that is the next experiment.

I tried the obvious version of it: --kv-resident 65536 with --max-context 131072 gave prompt 1,926 (+3%) and cut decode from ~116 to 112 (and in the one 237-run, to 112) because the KV took expert slots. So residency alone is not it — an int8-KV attention cost, or something in how the 262K buffers are sized, is the remaining suspect. If you have a specific --kv/context combination you consider canonical for Qwen IQ3_S on sm_80, I will run it exactly.

Happy to run more arms; the box is a single card for Qwen so restarts are ~50 s.

@linxuhao

Copy link
Copy Markdown
Contributor Author

Thanks for the careful rerun.

The build is not the gap. We ran your package (strata-engine-0.1.52-linux-x86_64-cuda-cuda12.tar.gz, CUDA 12.9, checksum verified) on our CMP against our own build of v0.2.7 (CUDA 13.3, sm_80 only). The source is the same: the package's BUILD.json names a42cc3d, which has the same src/ and CMakeLists.txt as the v0.2.7 tag. Same arguments, two interleaved runs per arm, 250 W:

our build, switches cuda12 package, switches our build, stock cuda12 package, stock
4K prompt 4,070-4,190 4,010-4,125 2,150-2,275 2,150-2,240
16K prompt 4,415-4,480 4,400-4,430 2,965-3,130 2,930-3,115
52K prompt 4,347-4,366 4,333-4,342 3,266 3,257-3,266
decode 124-128 124-128 109-111 109-111

"Stock" means only STRATA_PF_FUSED=1 STRATA_OWNED_PRICE=exact STRATA_NUMA_POOL=0. With the switches, the 11 greedy answers were identical between the two builds.

How we measure the prompt rate. We take it from the engine's own log line, for example prompt 51834 tokens = 0 reused + 51834 read in 11872 ms (4366.1 tok/s). That excludes the HTTP round trip and tokenizing the request. If your rate is (prompt_tokens - cached_tokens) / wall time on the client, at 52K-78K some of the 30% may be outside the engine. The same line in your engine log would tell.

--no-prefill-borrow is still there in 0.1.52. It is parsed in src/program/generate.cpp (line 3143 at v0.2.7) but is missing from --help; docs/INTEL.md, docs/OLDER_GPUS.md and docs/BATCHING.md mention it. With it, the log says strata serve: the prompt path allocates its own buffers. Without it, you should see the prompt path borrows N CUDA0 cache slots. On your Gen4 x16 link, refilling the borrowed slots costs much less than on our Gen2 x4, so we expect a smaller gain than we saw.

Our KV settings: --kv int8 --max-context 262144, no --kv-resident, one slot. The whole 256K KV sits in VRAM beside all 24,576 expert slots. STRATA_OWNED_PRICE=exact is what makes the own prompt buffers fit (3,854 MiB at chunk 8192). Our full argument line:

--expert-cache auto --prefill 8192 --spec 3 --spec-min-p 0.5 --mtp <mtp dir> --max-context 262144 --kv int8 --ple-io ram --pool-workers 9 --conversation-cache-mib 8192 --no-prefill-borrow --no-ple-prefetch

Agreed on STRATA_NUMA_POOL=0: it is a property of our box, not a default to recommend.

🤖 Generated with Claude Code

@48871697-creator

Copy link
Copy Markdown

Both corrections accepted — thank you.

--no-prefill-borrow does exist. I was wrong: my --help grep missed it, and the engine accepts it (plus --no-ple-prefetch, --pool-workers, --conversation-cache-mib) — a fake-pack probe reaches --ple-gguf is required instead of unknown argument. I withdrew that claim.

Your exact argument line, on our A100

Same package (cuda12, checksum verified), one A100 80GB PCIe, driver 580.126.09. Client-side timing, one unique nonce per probe, prompt_tokens - cached_tokens:

arm decode prompt 52K prompt 78K
your args, stock (PF_FUSED + OWNED_PRICE only) 120.0 / 123.1 2,102 2,035
your args + your 20 switches 138.2 2,809 2,686
your CMP (for reference) 109–111 / 124–128 3,257–3,266 4,333–4,366

So: with identical flags and an identical engine, our decode lands on your numbers, and our prompt is still ~35% below. I agree this is not build and not configuration any more.

A caution on our numbers before you trust the shape of them: decode on this box has outliers we cannot reproduce. Single arms gave 209.7, 236.8 and 277.8 tok/s, and repeating the same arm gave 120.0 / 123.1 on one and 277.8 / 139.1 within one arm. Prompt is stable within ~5%; decode needs repeats. Everything above is a repeat, not a single run.

Where I think the remaining 35% is, and a question

The difference from your box that we have not eliminated: two sockets and 1 TB of RAM that is ~50% busy with other jobs. --ple-io ram (your setting, now ours) puts the ~29 GB PLE table in host RAM, and on this box a good part of it lands on the far node. STRATA_NUMA_POOL=0 does not fix it reproducibly (one run 236.8, repeats 120.0 / 123.1), so I don't think the expert pool is the culprit.

Two things that would settle it:

  1. Is there a way to get the engine-side line on the Qwen engine? Ours only prints [strata] reading the prompt: 16,384 of 78,046 tokens, 9 s so far, never prompt N tokens = 0 reused + N read in X ms (Y tok/s). With your line we would compare like-for-like instead of subtracting client overhead.
  2. Do your prompt profiles show PLE reads dominating? If yes, we will pin the engine to one socket (numactl --cpunodebind=1 --membind=1) and/or run --ple-io direct with repeats, to see whether the 35% moves. If PLE is not the cost, the next suspect is our RAID array behind /opt/strata for any read the prompt path still does from disk.

Adopted your KV settings meanwhile: --kv int8 --max-context 262144, no --kv-resident, --ple-io ram --pool-workers 9 --conversation-cache-mib 8192 --no-prefill-borrow --no-ple-prefetch, plus --prefill 8192 --spec 3 --spec-min-p 0.5. Our prompt went from 1,703 to 2,809 at 52K, so the config work was worth it.

@bitaccesscomau

Copy link
Copy Markdown

The engine-side line is in the child engine's stdout/stderr log, not the server's progress stream. A clean run on my RTX 3090 emitted:

strata serve: prompt 14481 tokens = 0 reused + 14481 read in 8074 ms (1793.5 tok/s), 256 generated in 2315 ms (110.6 tok/s), drafts accepted 164 of 227, 2 checkpoints

That line appeared without STRATA_PREFILL_TIMING=1; the setting adds the phase breakdown. In a separate v0.2.6 / engine 0.1.51 profile, a roughly 14.5K real-text request attributed 1,428 ms (18.8% of GPU timeline) to PLE, with 2,117 ms of host-side PLE work in an 8,465 ms prompt read. Filler prompts showed only 1.3 to 2.2% PLE. A warm-page-cache --ple-io mmap test improved the agent prompt by roughly 5.6 to 7.0%; the cold-disk case was not measured. This makes PLE worth isolating, but the profile was on a one-NUMA-node 3090, so it does not establish whether pinning or direct I/O will fix the A100 gap. I would test pinning and --ple-io direct as separate arms using unique prompts and the engine-side line.

@48871697-creator

Copy link
Copy Markdown

@bitaccesscomau's pointer was the missing piece: the line is in the config's log file (the child engine's own output), which in our launcher is a separate file from the server's progress stream. We had been grepping the progress stream, where the Qwen engine only prints reading the prompt: … s so far. Thanks — that changes the measurement.

Engine-side numbers with your exact argument line

Same package, one A100 80GB PCIe, --kv int8 --max-context 262144, no --kv-resident, --prefill 8192 --spec 3 --spec-min-p 0.5, your 20 switches, OWNED_PRICE=exact, PF_FUSED=1. Three --ple-io modes, each a fresh restart, one 78,048-token filler prompt per arm:

arm prompt line (engine)
--ple-io ram 78051 = 0 reused + 78051 read in 15602 ms (5002.5 tok/s)
--ple-io direct 78048 = 0 reused + 78048 read in 15647 ms (4988.1 tok/s)
--ple-io mmap 78049 = 0 reused + 78049 read in 15601 ms (5002.8 tok/s)

Decode on the same arms: 165.6 / 168.2 / 181.7 tok/s, drafts accepted 97/110, 145/157, 159/168. So on filler prompts PLE I/O mode makes no difference here, exactly as you predicted; and the rate is ~5,000 tok/s, i.e. above the CMP's 4,333 at 52K, with decode 166–182 against 124–128.

The 35% "gap" was our environment, not the card

Here is what produced the low numbers I posted before, and I should flag it because other A100 reporters may hit it too: this box also ran a second model service holding 52 GB of VRAM and ~106 GB of host RAM. With that service running, our engine-side prompt line read 78048 = 0 reused + 78048 read in 34576 ms (2257.3 tok/s) — the same engine, same flags, same prompt. After the second service was stopped (host RAM went 641 → 535 GB used), the same arm read 5,002 tok/s. Decode went from 120–123 to 165–182.

So: under host-memory pressure the prompt path loses about half its speed on this machine, and it is invisible in the client-side numbers we were reporting. Your 3,257 / 4,333 and our 5,002 are consistent once the hosts are compared at similar occupancy.

I also tried the pinning arm you suggested (numactl --cpunodebind=1 --membind=1 on the server, so the engine inherits): loading stalled past 5 minutes on the restricted node and I abandoned it — with 700 GB of RAM in use on this box, --membind is not a clean experiment. If we get a quiet window I will rerun it, but the pressure explanation above is a better fit for the data than NUMA placement.

Two small things back to you: (1) STRATA_PREFILL_TIMING=1 does exist on 0.1.52 and is useful — the phase breakdown would let us separate PLE from the rest on real text; (2) if you have a real-text prompt where PLE dominates (18.8% in your profile), post the text or a similar one and we will run it here with the same flag, since our filler probes cannot show that.

@linxuhao

linxuhao commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that settles it. Host memory pressure halving the prompt rate is worth knowing, and the engine-side line makes it visible.

One note for anyone comparing against our numbers: our CMP is not at stock clocks. It runs with a +250 MHz V/F offset (an undervolt), a 1,650 MHz ceiling and a 250 W limit for Strata. Going from a 1,350 to a 1,650 MHz ceiling gave Strata +14% on prompts and +21% on decode on this card. Your A100 at stock clocks has 108 SMs against the CMP's 70, so it is no surprise that it comes out ahead once the host is quiet.

🤖 Generated with Claude Code

@48871697-creator

Copy link
Copy Markdown

That clock note explains the CMP's numbers well (and it makes the 108 vs 70 SM comparison honest). Two things from our side, both with STRATA_PREFILL_TIMING=1.

PLE on A100: 0.3–0.5%, not 18.8%

--ple-io ram locks the 320,001,536-row table in RAM in 2.2 s here. Timing of a real-text prompt (29,641 tokens, real markdown/code, not filler) and a filler prompt (78,040 tokens):

phase real text 29,641 tok filler 78,040 tok
gemm gate/up 38.2% 36.0%
qsa attn 12.7% 13.4%
hc read 9.3% 8.4%
gdn 9.3% 7.8%
qsa select 4.2% 8.4%
gdn recurrence 5.8% 5.6%
hc write 4.8% 5.1%
qsa proj 4.3% 4.3%
router+shared 2.4% 2.5%
gdn out proj 2.3% 2.4%
embed+steps 1.9% 1.4%
combine 1.9% 2.0%
gdn conv+gates 1.7% 1.8%
ple 0.5% 0.3%
gather 0.5% 0.4%
qsa indexer 0.0% 0.0%

Host side: PLE 1 ms (real) / 6 ms (filler), waiting for each chunk 2418 ms (real) / 6709 ms (filler). GPU timeline 9,675 ms real vs 25,402 ms filler.

So on this card PLE is not a factor at all — the prompt is ~36–38% expert GEMMs (gemm gate/up), then attention. We cannot reproduce your 18.8% case; if a profile of yours shows it, my guess is it is a 3090 property (PY4/24 GB, different SM count and L2) rather than a model property, but that is a guess.

For anyone reporting numbers from a shared box: state the host's free RAM

Same flags, same engine, same 78K filler prompt, four runs today:

engine line host RAM available
78048 read in 34576 ms (2257.3 tok/s) ~366 GB (a second model service running, 52 GB VRAM + 106 GB RAM)
78051 read in 15602 ms (5002.5 tok/s) ~472 GB (just after that service stopped)
78044 read in 26233 ms (2975.1 tok/s) ~155 GB (another user's process now holds 546 GB)
78047 read in 25772 ms (3028.4 tok/s) same, repeat

That is a 2.2× spread from host occupancy alone, with 0 reused in every line, so it is not the prompt cache. --conversation-cache-mib 8192 vs 0 changed nothing here, and STRATA_PREFILL_TIMING=1 changed nothing measurable either (2,975 with it, 3,028 without) — so the spread is the host, not our flags. On a quiet box we land at ~5,000 tok/s, i.e. where your card lands after its clock advantage; on a busy one we land at 3,000. Our A100 is PCIe with 2.0 TB/s HBM, 108 SMs, stock clocks, driver 580.126.09.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants