// include/strata/core/layer.hpp - one GDN LAYER, composed. P2.S5's mixer. // // This is the first place in the engine where the kernels are COMPOSED rather than tested individually, so // the thing it has to get right is the ORDER and the buffers, not the arithmetic. Every kernel it calls is // parity-tested on its own; what can go wrong here is a buffer sized for the wrong shape, a step done in the // wrong order, or a value that should have been converted and was not. // // `ref/gdn.py::gdn_decode` is the specification, and the order below is its docstring verbatim: // // qkv = wqkv @ x (10240 = q|k|v, each a contiguous run of 128-wide heads) // conv -> SiLU (SiLU is applied to the WHOLE conv output before the split) // q,k = l2_norm (v is NOT normalised) // beta = sigmoid(wbeta @ x) // gate = softplus(walpha @ x + dt) * ssm_a // recurrent step // y = rms_norm(o, ssm_norm) * sigmoid(z) <- SIGMOID for qwen4exp; qwen3.5 used SiLU // out = ssm_out @ y // // FOUR THINGS IN THAT ORDER ARE EASY TO READ PAST, and each produces a plausible number: // // * **SiLU is applied BEFORE the split**, to the whole 10240-wide conv output. Applying it only to q and // k, or after the reshape, is the same arithmetic on different data. // * **v is NOT normalised.** `l2_norm` applies to q and k only - two rows of 16 heads, not the 48 of v. // * **the `/hc`-style scale on q is `1/sqrt(S)`**, applied by the CALLER because `ref/gdn.py` says the two // llama.cpp paths apply it in different places. It goes on AFTER the norm. // * **the state is transposed** relative to the reference: (S, h_v, S) here, (S, S, h_v) there. That is // `gdn.hpp`'s choice and it is the reason `gdn_step`'s arguments are laid out as they are. // // THE ACTIVATIONS FOLLOW THE CONTRACT, which is why three different GEMVs appear below: `attn_qkv`, // `attn_gate` and `ssm_out` are K-quants (Q8_K), while `ssm_alpha` and `ssm_beta` are BF16 (bf16). Using one // activation format for all five would be a 0.66-1.41% error on the three that matter - measured, in // `s_gemv_q8k_parity` and `bf16_gemv_parity`. #pragma once #include "strata/core/layout.hpp" #include "strata/core/weights.hpp" #include "strata/kernels/gr.hpp" #include "strata/kernels/kv_stream.hpp" #include "strata/kernels/ngram.hpp" #include "strata/kernels/ple.hpp" #include #include #include #include namespace strata::core { /// Select pinned CUDA BF16/F32 projections for SSM gates, routing and the sparse indexer. /// Configure before session capture; captured graphs retain the selected implementation. /// Does not change GR, PLE or the shared expert's scalar gate. void layer_set_native_bf16(bool enabled); // Diagnostic vector-attention adapter; configure before capture, context <=256. void layer_set_native_flash_attn_short(bool enabled); /// Every buffer one GDN layer needs, carved from an arena ONCE. Sizes are functions of `ModelGeometry`, and /// `gdn_buffers_bytes` returns the total so a caller can size the arena before committing. struct GdnBuffers { // the activation, in the formats the contract asks for. BOTH quantized images are produced for every // layer, because WHICH ONE A WEIGHT WANTS IS A PROPERTY OF THE WEIGHT and the pack mixes them by layer - // `attn_qkv` alone is IQ4_XS on 13 layers, Q3_K on 18, Q4_K on 4 and S2 on 1. uint8_t* x_q8k = nullptr; ///< n_embd, block_q8_K uint8_t* x_q8_0 = nullptr; ///< n_embd, block_q8_0 uint16_t* x_bf16 = nullptr; ///< n_embd, bf16 bits // the qkv path float* qkv = nullptr; ///< conv_channels float* conv_out = nullptr; ///< conv_channels float* h = nullptr; ///< conv_channels = silu(conv_out), and it IS q|k|v in place // the per-head scalars and the recurrence float* alpha = nullptr; ///< h_v float* beta = nullptr; ///< h_v float* gate = nullptr; ///< h_v float* o = nullptr; ///< h_v * S float* z = nullptr; ///< value_dim float* y = nullptr; ///< value_dim uint8_t* y_q8k = nullptr; ///< value_dim, block_q8_K uint8_t* y_q8_0 = nullptr; ///< value_dim, block_q8_0 // the recurrent state, which outlives the call float* state = nullptr; ///< S * h_v * S float* conv_state = nullptr; ///< conv_channels * (d_conv-1) }; /// Bytes needed for `b`, 16-byte aligned between regions. uint64_t gdn_buffers_bytes(const ModelGeometry& g); /// Carves `base` (which must be DEVICE memory) into `b`. Returns the bytes used. uint64_t gdn_buffers_init(const ModelGeometry& g, void* base, GdnBuffers& b); /// Zeroes the recurrent and conv state, so a fresh sequence starts from the reference's own `zeros()`. void gdn_buffers_zero_state(const GdnBuffers& b, const ModelGeometry& g, void* stream); /// `Mixed` (n_embd f32) through one GDN layer to `out` (n_embd f32). `state` and `conv_state` are updated. /// /// `layer` selects the weights; `tables` must outlive the call. Returns false and fills `err` if a tensor is /// missing - which is a wiring mistake here rather than a shape problem, because `check_layer` already /// asserted the shapes at load time. bool gdn_layer(const WeightTable& tables, const ModelGeometry& g, int64_t layer, const GdnBuffers& b, const float* mixed, float* out, void* stream, std::string& err); // ================================ the MoE block ================================ struct Doorbell; // defined with the rest of the handoff, below; only a pointer is needed here /// Buffers for the MoE block, on EVERY layer. struct MoEBuffers { uint16_t* x_bf16 = nullptr; ///< n_embd, for the ROUTER's weight, which is BF16 uint16_t* x_f16 = nullptr; ///< n_embd, for the SHARED EXPERT's weights (see the note in `moe_layer`) float* logits = nullptr; ///< n_expert int* ids = nullptr; ///< k float* weights = nullptr; ///< k float* shared = nullptr; ///< n_embd float* sh_scratch = nullptr; ///< shared_expert_scratch_bytes(n_ff): the shared expert's own scratch uint8_t* x_q8_0 = nullptr; ///< n_embd, block_q8_0 - the shared expert's weights want Q8_0 on the layers ///< whose types are Q2_0/Q4_0/Q5_0/Q8_0/IQ4_NL, which is most of them uint8_t* x_q8k = nullptr; ///< n_embd, block_q8_K - and Q8_K on the K-quant ones }; uint64_t moe_buffers_bytes(const ModelGeometry& g, int64_t k); uint64_t moe_buffers_init(const ModelGeometry& g, int64_t k, void* base, MoEBuffers& b); /// The MoE block: router -> (the caller's expert outputs) -> combine. /// /// `parts` is (k, n_embd) device f32 and is **filled by the CALLER** - by the CPU pool and the VRAM-resident /// experts. That split is the architecture's, not a shortcut: the routed experts are the only part of the /// model that does not fit in VRAM, and every design decision in this engine exists to keep them out of the /// graph. This function therefore owns the two ends - which experts, and how their outputs are combined - /// and deliberately not the middle. /// /// TWO ACTIVATION FORMATS AGAIN, and the router's is the one that matters most: /// /// * `ffn_gate_inp.weight` is BF16, so the router sees a BF16 activation. `ref/moe.py` L88-92 calls this /// "the highest-consequence rounding in the model": the logits were measured **8.100e-03** off without it, /// which is enough to FLIP a top-10 selection - and a flipped selection is not a small numeric error, it /// is a different expert reading a different weight. /// * the shared expert's weights are Q3_K/IQ4_XS/Q5_0 IQ-quants whose contract is Q8_K, which /// `shared_expert` does not implement; it takes fp16. That is the known gap its own header documents and /// `docs/activation-contract.md` names as outstanding. bool moe_layer(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& b, const float* x, const float* parts, float* out, void* stream, std::string& err, const Doorbell* db = nullptr); // ================================ THE MOE, SPLIT AT THE ROUTER ================================ // // **THE SPLIT EXISTS BECAUSE THE ONE-LAYER DELAY WAS NOT A TIMING CHANGE.** `moe_layer` is // `route -> publish -> shared expert -> combine`, and `combine` multiplies `parts` by THIS layer's // `b.weights`. So `parts` must be THIS layer's expert outputs. A host loop that hands layer `l`'s pool // result to layer `l+1` computes `sum_j w_{l+1}[j] * expert_{ids_l,j}(x_l)` while the model is // `sum_j w_l[j] * expert_{ids_l,j}(x_l)` - **both the expert selection and the input one layer stale while the // weights are current** - and `session.hpp` states the contract it violates: the doorbell is "a change to WHEN // the host runs, not to what this function computes". // // The one-layer delay was introduced for a real reason - it is the only way to overlap a CPU pool with a GPU // that has already queued the combine - and the fix is to split the GRAPH, not to delay the answer: // // block_layer_pre[l] attn -> mixer -> gr_write -> ffn gr_read -> moe_route [rings the doorbell] // ... the host runs the pool here, on the ids `moe_route` just published ... // block_layer_post[l] moe_finish -> gr_write // // `post[l]` is launched AFTER the pool returns, so it sees layer `l`'s experts with layer `l`'s weights, and it // is still a captured graph. What it costs is the overlap: the window is now whatever GPU work follows the // ring inside `pre[l]` - the shared expert and nothing else, because everything after the combine depends on // `parts`. **A per-layer CPU pool cannot be hidden behind a strictly serial residual chain**, which is why // Phase 3's VRAM expert cache is the answer to the CPU term and the doorbell is not. /// The routing half: bf16 activation, the router GEMV, top-k, and the doorbell publish. Ends with the ring /// rung, so a host that sees it knows `h_x_f`/`h_ids`/`h_weights` are in place. bool moe_route(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& b, const float* x, void* stream, std::string& err, const Doorbell* db = nullptr); /// The finishing half: the shared expert and the combination. /// /// **`parts` MUST BE THE EXPERTS THIS LAYER'S OWN ROUTER SELECTED.** It is combined with `b.weights`, which /// `moe_route` wrote for THIS layer. Passing the previous layer's vectors is the L100 bug: it produces a /// finite, fluent, deterministic token that is not the model's. bool moe_finish(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& b, const float* x, const float* parts, float* out, void* stream, std::string& err); /// Plan v0.3 P3: the two parts of `moe_finish` separately. `moe_shared` computes the shared expert into /// `b.shared` and needs only `x`; `moe_combine_parts` combines `parts` with `b.weights` and `b.shared`. /// `moe_finish` is exactly `moe_shared` followed by `moe_combine_parts`. bool moe_shared(const WeightTable& tables, const ModelGeometry& g, int64_t layer, const MoEBuffers& b, const float* x, void* stream, std::string& err); bool moe_combine_parts(const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& b, const float* parts, float* out, void* stream, std::string& err); /// Plan v0.3 P3 (default ON): the shared expert runs at the END OF `pre[l]`, after the doorbell has rung, so /// the GPU computes it while the host runs the CPU pool; `post[l]` then only combines. Same kernels on the /// same inputs in the same stream order relative to their consumers, so the result is bitwise unchanged. /// Must be set before capture. `false` restores the shared expert inside `post[l]` (A/B arm). void layer_set_shared_early(bool enabled); bool layer_shared_early(); // ================================ the QSA mixer ================================ /// THE PERSISTENT STATE OF ONE QSA LAYER FOR ONE SEQUENCE: the KV cache, the indexer's pooled keys, the rope /// tables, and the per-token step buffer. None of it is scratch - it survives every token. /// /// `max_cells` is the sequence capacity the arena was sized for, and it fixes the rope table's length: a table /// shorter than the sequence would have `rope_neox_apply` read past it, which is a wrong rotation rather than a /// fault. struct QsaState { uint16_t* k_pool = nullptr; ///< [page][kv_head][page_size][head_dim] fp16 (null in INT8 mode) uint16_t* v_pool = nullptr; /// Plan v0.3 P7: INT8 KV (qsa_set_kv_int8). Codes [page][kv_head][page_size][head_dim], one FP16 scale per 64. bool kv_int8 = false; int8_t* k_q = nullptr; int8_t* v_q = nullptr; uint16_t* k_scale = nullptr; uint16_t* v_scale = nullptr; /// PR #21: Q4_0 KV with Walsh-Hadamard rotation (qsa_set_kv_q4, kv_q4.hpp): 144 B per cell and head bool kv_q4 = false; uint8_t* k_q4 = nullptr; uint8_t* v_q4 = nullptr; /// Hybrid K8V4: K in INT8 (unrotated - the scores stay exact), V in rotated Q4_0 (kv_q4.hpp): 816 B per cell. /// Uses k_q/k_scale + v_q4. Mode 0 only (no KV streaming, no ring); under this setting the MTP drafter's /// ring state stays plain INT8, so the block movers never see the hybrid layout. bool kv_hybrid = false; /// K and V go through kv_q4.hpp's Walsh-Hadamard rotation before they are stored, the queries too, the output /// back: always for Q4_0, for INT8 by qsa_set_kv_int8_rotate (spreads outlier channels over the scale groups) bool kv_rot = false; int32_t* page_table = nullptr; ///< (n_pages,) logical page -> physical page (-1: not resident, streamed) int64_t n_pages = 0; int64_t max_cells = 0; /// KV STREAMING (docs/kv-streaming-design.md, `kv_stream.hpp`). `kv_mode` 0: every page in VRAM, identity /// table (n_slots == n_pages, no host copy). 1: streamed - the authoritative K/V in `host`, `n_slots` pages /// in VRAM, `map` resolves the selection's blocks on device. 2: a ring (the MTP drafter's window) - `n_slots` /// pages at `block % n_slots`, with the host copy for a resume. The pool pointers above point at the slots. int kv_mode = 0; int64_t n_slots = 0; strata::kernels::KvHostPools host; strata::kernels::KvStreamMap map; int64_t idx_pooled_rows = 0; ///< rows of `idx_pooled` (a ring, which has no indexer, keeps 2) float* idx_tail = nullptr; ///< (idx_block - 1, idx_dim): the raw tail of the block being filled float* idx_dead = nullptr; ///< (idx_dim,): the spare slot's key, CONSTANT for the sequence float* idx_pooled = nullptr; ///< (max_cells/idx_block + 2, idx_dim) int32_t* idx_block_pos = nullptr; float* cos_tab = nullptr; ///< (max_cells, n_rot/2), built on the HOST in float64 bool owns_rope = false; ///< built the table above (not borrowed with share_rope): it releases it float* sin_tab = nullptr; /// THE PER-TOKEN COUNTS, IN DEVICE MEMORY - the whole reason this layer can be a graph. `qsa_step_fill` /// computes all four from the position, so one H2D per token updates every count every kernel needs. int32_t* step = nullptr; int32_t* attention_status = nullptr; ///< per-layer device status, captured D2H to host_step[kStepCount] /// The rope positions, (n_head,) int32, all equal to the current position. `rope_neox_apply` reads its /// positions from the DEVICE, so a host scalar here would be an illegal access (and uncapturable). int32_t* pos_dev = nullptr; /// PINNED HOST STAGING AT FIXED ADDRESSES, and they are not an optimisation. The uploads in `qsa_layer` /// copy FROM these, so a CUDA graph captures the SOURCE POINTER - and the first version of the layer filled /// a stack array and a local `std::vector` instead. Those addresses are dead by the time the graph is /// replayed, which is this project's own recorded pitfall ("graph inputs live at fixed addresses") walked /// straight into: the replay would read whatever the stack held, and the step counts would be wrong in a way /// that looks like a working token. /// /// Pinned as well as fixed, because a pageable source would make `cudaMemcpyAsync` a synchronous staged /// copy and give back the overlap the doorbell protocol exists for. int32_t* host_step = nullptr; ///< (kStepCount + 1,) pinned; final element is attention status int32_t* host_pos = nullptr; ///< (n_head,) pinned }; /// Plan v0.3 P7: the RoPE cos/sin table (max_cells x n_rot/2 x 2 floats, 64 MiB at 262K) is identical in every /// QSA layer. `with_rope = false` sizes a state that borrows it; `share_rope` points `st` at another state's table /// instead of building a copy (the session builds it once, in the first QSA layer). uint64_t qsa_state_bytes(const ModelGeometry& g, int64_t max_cells, bool with_rope = true, int64_t ring_cells = 0); /// KV streaming: keep `cells` cells of each QSA layer in VRAM and the rest in pinned host memory (0: all in VRAM, /// the default). Set before sizing and initializing the session; a context that fits in `cells` is not streamed. /// Also puts the MTP drafter's K/V in a ring of its window (`ring_cells` of qsa_state_bytes/init; -1 forces a fully /// resident state). void qsa_set_kv_resident(int64_t cells); int64_t qsa_kv_resident(); /// The fewest resident cells a streamed layer may have: one verify window's selections (8 queries x 2,051 cells /// in whole blocks) must fit at once, with room to spare. int64_t qsa_kv_resident_min(); /// Pinned host bytes the streamed states hold (their host copies). uint64_t qsa_kv_host_bytes(); /// Plan v0.3 P7: store K/V as INT8 with FP16 scales per 64 values (half the VRAM of FP16). Set before sizing and /// initializing the session; default off until gate G-C accepts it. void qsa_set_kv_int8(bool enabled); /// INT8 K/V through the Hadamard rotation (off by default: STRATA_KV_ROT=1) void qsa_set_kv_int8_rotate(bool enabled); bool qsa_kv_int8(); /// PR #21: store K/V as Q4_0 after a Hadamard rotation (`--kv q4_0`): 576 B per cell, vs 1,056 in INT8. void qsa_set_kv_q4(bool enabled); bool qsa_kv_q4(); /// Hybrid K8V4 (`--kv k8v4`): K in INT8, V in rotated Q4_0 - 816 B per cell. Not with --kv-resident. void qsa_set_kv_hybrid(bool enabled); bool qsa_kv_hybrid(); /// The state's KV format for the block-moving functions of kv_stream.hpp (kKvF16 / kKvInt8 / kKvQ4). inline int qsa_kv_format(const QsaState& st) { // A hybrid K8V4 state is mode 0 only and never reaches the block movers; refuse rather than let it // fall through to kKvF16 - a wrong layout silently applied is worse than a hard stop (PR review). if (st.kv_hybrid) { std::fprintf(stderr, "strata: qsa_kv_format: a hybrid K8V4 state must never reach the block movers\n"); std::exit(1); // the kernels' own "unsupported geometry" convention (kv_q8.cu, qsa_decode_attn.cu) } return st.kv_q4 ? strata::kernels::kKvQ4 : st.kv_int8 ? strata::kernels::kKvInt8 : strata::kernels::kKvF16; } uint64_t qsa_state_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaState& st, const QsaState* share_rope = nullptr, int64_t ring_cells = 0); /// KV streaming: the pools a reader sees (the VRAM slots) and, when streamed, make the selection's blocks resident. strata::kernels::QsaAttnPools qsa_attn_pools(const QsaState& st); void qsa_kv_resolve(const QsaState& st, const ModelGeometry& g, const int32_t* ids, const int32_t* steps, int64_t n_q, int64_t cap, void* stream); /// Zeroes the pools AND the indexer, so a fresh sequence matches the reference's own `zeros()`. The KV pool /// matters even for cells that are never attended, because `kv_gather` reads whatever the selection names. void qsa_state_zero(const QsaState& st, const ModelGeometry& g, void* stream); // ================================ PER-STAGE TIMING, DEBUG ONLY ================================ // // Event pairs around the pieces of `block_layer_pre`/`_post`, recorded per (layer, stage) and read once at the // end so that **NOTHING IS SYNCHRONISED DURING THE RUN** - a per-stage sync would serialise the pipeline and // report the serialised time. **CAPTURED GRAPHS CANNOT USE IT**: an event record inside a stream capture is // silently dropped, so this is for the `--no-capture` path only. // // It exists because the engine spends ~1.047 ms per layer with the experts off while every cost model in // `bench/` predicts less than half that, and the gap has to be attributed before it can be attacked. // slot 0 gr_read(attn) 1 the attention block 2 gr_write(attn) 3 gr_read(ffn) 4 router // slot 5 moe_finish 6 gr_write(ffn) bool stage_timing_enable(); void stage_timing_name(int slot, const char* name); void stage_timing_report(int64_t n_layers); /// The same event pairs, reachable from `session.cpp`, for marks that sit OUTSIDE the captured graphs - the /// two `cudaGraphLaunch` calls per layer. Those are ordinary stream operations, so an event around them is /// legal and is not captured, which is what lets the captured path's 1.047 ms/layer be split into the two /// graphs' execution and the gap the host spends between them. Slots 16 and 17 are reserved for it; slots /// 0-15 are the in-layer marks and are only meaningful with `--no-capture`. void stage_mark_begin(int64_t layer, int slot, void* stream); void stage_mark_end(int64_t layer, int slot, void* stream); /// Scratch for one QSA layer. Every buffer here is overwritten by every token. struct QsaBuffers { uint8_t* x_q8_0 = nullptr; ///< n_embd, block_q8_0 - `attn_q` is Q2_0, whose vec_dot_type is Q8_0 uint8_t* x_q8k = nullptr; ///< n_embd, block_q8_K - the three K-quant projections uint16_t* x_bf16 = nullptr; ///< n_embd - the two BF16 indexer projections float* q_full = nullptr; ///< n_head * 2 * head_dim: the raw `attn_q` output, q | gate per head float* qcur = nullptr; ///< n_head * head_dim: the FIRST half of each head, contiguously float* kcur = nullptr; ///< n_head_kv * head_dim float* vcur = nullptr; ///< n_head_kv * head_dim float* idx_raw = nullptr; ///< idx_key_dim: NEVER normed and NEVER rotated float* q_idx = nullptr; ///< idx_q_heads * idx_key_dim float* cell_scores = nullptr; ///< max_cells int32_t* ids = nullptr; ///< cap = qsa_selection_width(kTopkMaxCells, s) uint16_t* k_scratch = nullptr; ///< cap * n_head_kv * head_dim uint16_t* v_scratch = nullptr; float* attn = nullptr; ///< n_head * head_dim uint16_t* attn16 = nullptr; ///< n_head * head_dim, for a fp16-activation consumer /// `attn * sigmoid(gate)` in f32 and then in Q8_K - what `attn_output` ACTUALLY wants. Its weights are /// Q4_K/Q5_K/Q6_K, whose `vec_dot_type` is Q8_K, and the fp16 path is worth 0.66-1.41% against the /// reference. `docs/activation-contract.md`. float* attn32 = nullptr; uint8_t* attn_q8k = nullptr; float* attn_scratch = nullptr; ///< plan v0.3 P3: split-K decode attention partials }; uint64_t qsa_buffers_bytes(const ModelGeometry& g, int64_t max_cells); uint64_t qsa_buffers_init(const ModelGeometry& g, int64_t max_cells, void* base, QsaBuffers& b); /// `x` (n_embd f32) through one QSA layer to `out` (n_embd f32) at sequence position `pos`. /// /// `pos_base` is the sequence's FIRST cell's position, which the indexer needs and cannot derive - equating a /// cell's position with its index is only right for a sequence starting at 0. /// /// THE OP ORDER IS `ref/model.py::_qsa`, and two things in it are easy to read past: /// /// * the indexer's raw key is appended BEFORE any norm or rotation, and it is the raw `indexer.k_proj` /// output that is pooled and rotated LATER, once per block and at the block's FIRST cell; /// * `q_full` is split into q and gate by HALVES of each head's 2*head_dim block, contiguous, not /// interleaved - `qsa_gate_apply` reads the second half and `qcur` is copied out of the first. /// /// THE THREE ACTIVATION FORMATS FOLLOW `docs/activation-contract.md`, and they are not interchangeable: /// `attn_q` is Q2_0 (Q8_0 activation), `attn_k`/`attn_v`/`attn_output` are K-quants (Q8_K), and the two /// indexer projections are BF16 (bf16). bool qsa_layer(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t pos, int32_t pos_base, const QsaState& st, const QsaBuffers& b, const float* x, float* out, void* stream, std::string& err, float* dump = nullptr); // ================================ THE DOORBELL ================================ /// THE HANDOFF: `x_f` and the miss list reach the CPU WHILE THE LAYER IS STILL RUNNING. /// /// Everything about the shape of this struct comes from two measurements, and neither was a guess: /// /// * **THE WAIT MUST POLL THE DRIVER.** Round 195: a host spin that only reads a memory location never runs /// the kernel - 5,907,703 spins over 500 ms and the datum flips the instant `cudaStreamSynchronize` is /// called and never before, because on Windows the driver BATCHES command submission and a memory-only /// spin gives it no reason to flush. So `h_seq` is read AND `cudaEventQuery` is called, which is a query /// and not a blocking sync - P2.X3's "zero synchronization calls in the layer loop" still holds. /// * **THE WRITE MUST BE CAPTURABLE, AND A KERNEL IS.** Round 199: `cudaEventRecord` inside a capture is /// SILENTLY DROPPED - 2 nodes for two kernels plus an event record, and the event never completed across a /// 41 ms graph. A mid-graph doorbell KERNEL is captured normally, and round 209 measured the host seeing a /// pinned handoff **0.050 ms** into a 39.8 ms graph. /// /// The memory is MAPPED PINNED, so it has one address for the device and another for the host and both refer /// to the same bytes. Pinned as well as mapped, because a pageable destination would turn the device's write /// into a staged copy and give back the overlap this exists for. struct Doorbell { int64_t n_embd = 0; int64_t k = 0; // the HOST's view float* h_x_f = nullptr; ///< n_embd int32_t* h_ids = nullptr; ///< k float* h_weights = nullptr; ///< k uint32_t* h_seq = nullptr; ///< 1 /// Plan v0.3 P3 token graph: the HOST's answer. The host writes the ring value it has served (after the /// pool's output is in place); the device's `doorbell_wait` spins until the flag equals the ring. uint32_t* h_flag = nullptr; ///< 1 // the DEVICE's view of the same bytes float* d_x_f = nullptr; int32_t* d_ids = nullptr; float* d_weights = nullptr; uint32_t* d_seq = nullptr; uint32_t* d_flag = nullptr; }; /// Allocates the four mapped-pinned regions. Returns the bytes, or 0 on failure. uint64_t doorbell_init(const ModelGeometry& g, int64_t k, Doorbell& db); void doorbell_free(Doorbell& db); /// Zeroes `h_seq` (and `h_flag`), so a caller can wait for it to become `expected` from a known state. void doorbell_reset(const Doorbell& db); // ================================ ONE WHOLE BLOCK ================================ /// The gated-residual stack plus the two buffers the halves hand to each other. /// /// `R` is (hc, n_embd) and is BOTH the input and the output: `hc` residual streams, each n_embd wide, which is /// what `gr_read`/`gr_write` exist for. It is not one vector with a gate. struct BlockBuffers { float* R = nullptr; ///< (hc, n_embd) float* mixed = nullptr; ///< n_embd: what the mixer consumes float* block_out = nullptr; ///< n_embd: what a half produces float* inject = nullptr; ///< hc: `gr_read`'s per-stream injection, reused per half /// Plan v0.3 P3 fused GR: the FFN half's injection lives here (the attention half's stays in `inject`), /// because the fused read of one half reads the other half's injection while writing its own. float* inject2 = nullptr; ///< hc float* gr_rs = nullptr; ///< hc: the fused read's per-stream 1/rms uint8_t* head_q8k = nullptr; ///< `q8k_bytes(n_embd)`: the head's Q8_K activation image strata::kernels::GrWorkspace gr; ///< `gr_read`'s scratch /// THE HALF-LEVEL C1 ORACLE, and it is a HOST buffer of `(2 * n_embd + 2 * hc + n_head * head_dim)` floats /// PER LAYER: `[0, n_embd)` the attention half's `block_out`, `[n_embd, 2*n_embd)` the MoE half's, the two /// `hc`-vectors of `inject`, and finally the QSA layers' PRE-GATE attention (`n_head * head_dim`, unused by /// GDN). The residual ladder (`session_loop`'s `dump_layers`) says WHICH LAYER diverges; this says which /// stage, and it is the only way to separate the reference's `linear_attn_out-` from its `ffn_out-` /// without re-deriving one of them. The copies are issued from inside `block_layer_pre`/`_post`, so they /// are recorded as nodes of the per-layer captured graph and replay with the layer they belong to. Null /// means no dumping, which is the default and costs nothing. float* dump = nullptr; }; uint64_t block_buffers_bytes(const ModelGeometry& g); /// Plan v0.3 P3: the hyper-connection read as two fused kernels with the previous half's write folded in /// (`strata/kernels/fused_gr.hpp`). Requires the native MMVF contract (FP32 activations). The residual `R` /// then lags by one pending write between halves; it is materialised before the layer-1 PLE and after the last /// layer. Set before capture; not for the per-stage or dump measurements. void layer_set_fused_gr(bool enabled); bool layer_fused_gr(); /// Plan v0.3 P3/P7: QSA attention by the split-K kernel reading the KV pools directly (default ON; `false` keeps /// the gather + one-block-per-head kernel). Set before capture. void layer_set_fast_attn(bool enabled); /// Plan v0.3 P3: doorbell payload + ring as one kernel, QSA step staging read by a kernel from mapped memory /// (default ON; `false` keeps the memcpy nodes). Set before capture. void layer_set_publish_kernel(bool enabled); /// Plan v0.3 P3: GDN step + output norm as one coalesced kernel under the native contract (default ON). void layer_set_fused_gdn(bool enabled); /// Plan v0.3 P7: QSA block scores in FP32 and a radix top-k over blocks (default ON; `false` = the FP64 row scores /// and the bit-serial cell top-k). Set before capture. void layer_set_fast_select(bool enabled); /// Plan v0.3 P6: whether the current decode configuration is the one the speculative verify window reproduces /// bit for bit (native projections, fused GR and GDN, split-K attention, block selection, native indexer). bool layer_verify_compatible(std::string& why); uint64_t block_buffers_init(const ModelGeometry& g, void* base, BlockBuffers& b); // ================================ THE TWO ENDS OF A TOKEN ================================ /// THE EMBEDDING LOOKUP, done as a COLUMN DEQUANT rather than a GEMV. /// /// `token_embd.weight` is `[n_embd, n_vocab]`, so `out[i] = W[i][token]` - one column, `n_embd` values. Running /// `output`-style arithmetic on a one-hot activation would produce the same numbers with 248320x the work. /// /// **THE DECODE IS TRANSCRIBED FROM `s_gemv_q8k_split` AND MUST STAY BIT-IDENTICAL TO IT**: the code is the /// `CODE_BITS`-wide field, least-significant first, at bit `(i % PER_BYTE) * CODE_BITS` of byte `i / PER_BYTE`; /// the value is `(code + code_bias) * scales[i / group_elems] + offsets[i / group_elems]`; and the column's /// planes start at `token * n_in / PER_BYTE` and `token * n_in / group_elems`. A second decoder that drifts /// from the kernel's would make the prompt embed differently from the tokens generated after it. bool embed_row(const WeightTable& tables, const ModelGeometry& g, int64_t token, float* out_dev, void* stream, std::string& err); /// `ref/model.py` L520: `fin = GR.gr_read(R, output_hc_*..., inject=None, rms_eps); logits = output @ fin`. /// /// `R` is read from `bb.R` and the result goes to `logits`, which the caller sizes at `output.weight`'s `ne1` /// (the vocabulary). `bb.head_q8k` and `bb.mixed` are scratch. bool lm_head(const WeightTable& tables, const ModelGeometry& g, const BlockBuffers& bb, float* logits, void* stream, std::string& err); /// Compute the final residual mix in bb.mixed, before the output projection. This is shared by the /// canonical and experimental native head so only the projection's arithmetic changes in their A/B test. bool lm_head_mix(const WeightTable& tables, const ModelGeometry& g, const BlockBuffers& bb, void* stream, std::string& err); /// THE PER-LAYER OP ORDER of `phases/phase-2-correct-engine.md` P2.S5, architecture §6.1, for ONE layer: /// /// gr_read (attn) -> mixer -> gr_write (attn) /// gr_read (ffn) -> MoE -> gr_write (ffn) /// /// `layer` picks the mixer: `is_qsa_layer` selects `qsa_layer`, otherwise `gdn_layer`. `pos`/`pos_base` reach /// the QSA layer and are ignored by GDN, which has no position argument at all - its whole per-token input is /// `x` and what advances between tokens is its recurrent state. /// /// **`parts` IS THE CALLER'S**, and that is the architecture's split rather than an omission: the routed experts /// are the only part of the model that does not fit in VRAM, so the CPU pool and the VRAM-resident experts fill /// `parts` and this function owns the two ends. In P2.S5 everything is a miss, so a caller passes whatever the /// pool produced; the doorbell protocol is what lets it be produced DURING this layer rather than before it. // ================================ THE PLE (LAYER 1'S PER-LAYER EMBEDDING) ================================ // // **THIS MODULE EXISTED, WAS PARITY-TESTED, AND WAS NEVER CALLED - WHICH IS WHY GATE C1 FAILED.** Layer 1 is // the only layer with `blk.1.ple_*` tensors (six of them) and `block_layer` ran GDN/QSA + GR + MoE without it, // so the engine computed a different model. `ple_parity` verifies all three pieces against things that are // not this project's code - the hash against `ref/ngram.py` with the artifact's own constants, the IQ4_NL row // read against numpy over the original GGUF, and the block against ggml's own graph - and none of that helps // if nothing calls it. // // THE TABLE IS NOT IN THE PACK. `per_layer_token_embd.weight` is 51.2e9 elements (28.8 GB) of IQ4_NL in the // ORIGINAL second GGUF shard, and it is the only tensor this engine reads from the GGUF rather than from the // canonical pack. It is also why the PLE is cheap: sixteen 90-byte rows per token is 1440 bytes of traffic, // against the experts' 664 MB. struct PleRun { /// Null when the PLE is not wired (no table, or a pack without `blk.1.ple_*`). Then `block_layer_pre` /// SKIPS the module entirely, which is what every measurement before this change did. strata::kernels::PleTable* table = nullptr; strata::kernels::PleWeights w; /// The session's PLE state, by pointer so `block_layer_pre` needs no `SessionState`. const int32_t* token = nullptr; ///< the token being decoded const int32_t* prev = nullptr; ///< (2,) predecessors, OLDEST FIRST, `-1` where there is none float* hist = nullptr; ///< NG_HIST x hc_dim NORMALIZED history, row-fastest /// The gathered 2560-wide row vector on the DEVICE, and the host staging it is copied from: `gather` /// writes HOST memory (the table is a host mapping) and the block wants the device. float* emb_dev = nullptr; float* emb_host = nullptr; /// Internal workspace plus a disjoint normalized export for the next history row. /// Allocate ple_run_scratch_bytes(), not merely the lower-level ple_block_scratch_bytes(). float* scratch = nullptr; strata::kernels::PleConsts consts; bool ready() const { return table != nullptr && table->is_open() && hist != nullptr && emb_dev != nullptr && emb_host != nullptr && scratch != nullptr && token != nullptr && prev != nullptr && ((w.key_codes != nullptr && w.key_scales != nullptr) || w.key_bf16 != nullptr || w.key_native_data != nullptr) && w.value_bf16 != nullptr && w.norm_key != nullptr && w.norm_query != nullptr && w.norm_conv != nullptr && w.conv1d_f16 != nullptr; } }; /// **CALLED ONCE PER TOKEN, FROM THE DRIVER, *OUTSIDE* ANY CAPTURE.** The n-gram hash and the table gather /// are host operations, and `block_layer_pre` is captured - so doing them there would run them once, at capture /// time, and replay the same rows for every token forever. This is the doorbell's cloned-literal bug in a /// different coat: silent, finite, and stable. bool ple_stage_token(const PleRun& p, void* stream, std::string& err); /// Plan v0.3 P2: `ple_stage_token` in two halves, so the SSD reads overlap other host work. /// `ple_issue_token` hashes the token and starts its 16 row reads; `ple_finish_token` waits for them, /// dequantizes and queues the upload. Both are driver-side, outside any capture, like `ple_stage_token`. bool ple_issue_token(const PleRun& p, std::string& err); bool ple_finish_token(const PleRun& p, void* stream, std::string& err); /// Internal PLE workspace plus one separate hc_dim normalized row for advancing history. uint64_t ple_run_scratch_bytes(); /// `ple` is the layer-1 PLE, or null to skip it (which is what every measurement before LEDGER L123 did). /// It is applied INSIDE `pre[1]`, before layer 1's own `gr_read`, because the block's output is /// `hidden + gated + silu(conv)` on the residual itself - it is a residual UPDATE, and the architecture puts /// the gathered rows in place "before layer 2". bool block_layer(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t pos, int32_t pos_base, const GdnBuffers& gb, const QsaState& qst, const QsaBuffers& qb, const MoEBuffers& mb, int64_t k, const BlockBuffers& bb, const float* parts, void* stream, std::string& err, const Doorbell* db = nullptr, const PleRun* ple = nullptr); /// **THE BLOCK, SPLIT AT THE ROUTER, SO A HOST LOOP CAN PUT THE CPU POOL IN THE MIDDLE.** /// /// pre[l] gr_read(attn) -> mixer -> gr_write(attn) -> gr_read(ffn) -> moe_route [rings the doorbell] /// post[l] moe_finish -> gr_write(ffn) /// /// `parts` reaches only `post`, and it must be THIS layer's experts. The two calls are NOT independent: `post` /// reads `bb.mixed` and `bb.inject` exactly as `pre` left them, so nothing may run between them that touches the /// block buffers - and in particular `pre[l+1]` may NOT be launched between them, because its own `gr_read` /// overwrites both. That constraint is why the doorbell's window is the shared expert and nothing more, and /// why the CPU expert term is answered by Phase 3's VRAM cache rather than by this pipeline. /// /// **`half` EXISTS SO THE CAPTURED GRAPH CAN BE SPLIT FOR MEASUREMENT, AND IT IS THE ONLY REASON.** /// /// The plan's R3 items - GR, GDN, the router - are each worth several milliseconds and there has never been a /// per-stage breakdown of the CAPTURED graph to choose between them. The `--no-capture` table cannot supply /// one: round 312 showed its `moe_finish` figure was a host-gap artefact, four times the captured cost of the /// same work. An event recorded BETWEEN two `cudaGraphLaunch` calls IS valid, so the answer is to capture the /// layer as more than one graph and time them from outside. /// /// `half` selects a prefix of the stages, and the seam is the one this file already documents: /// /// 1 = stages 0..2 gr_read -> attention -> gr_write the MIXER /// 2 = stages 3..4 gr_read -> moe_route the FFN front and the ROUTER /// 0 = both, which is what every caller before this used and what `session_loop` still uses /// /// **A half is not a free-standing call.** `2` reads what `1` left in `bb.mixed`/`bb.inject`, exactly as /// `block_layer_post` does; the two are only meaningful replayed in order on one stream, which is what /// `session_replay_stages` does. bool block_layer_pre(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t pos, int32_t pos_base, const GdnBuffers& gb, const QsaState& qst, const QsaBuffers& qb, const MoEBuffers& mb, int64_t k, const BlockBuffers& bb, void* stream, std::string& err, const Doorbell* db = nullptr, const PleRun* ple = nullptr, int half = 0, int stage_prefix = 0); bool block_layer_post(const WeightTable& tables, const ModelGeometry& g, int64_t layer, int64_t k, const MoEBuffers& mb, const BlockBuffers& bb, const float* parts, void* stream, std::string& err); } // namespace strata::core